Compare commits

...
Author SHA1 Message Date
Philippe Loulidi da80f6d3ca [ecr] make Jev an active verification cascade 2026-09-21 12:22:48 -06:00
Philippe Loulidi 62c013a321 [ecr] add Jev shadow evaluation 2026-09-21 11:31:51 -06: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
41 changed files with 1166 additions and 120 deletions

No files matched your search

File renamed without changes.
@@ -31,6 +31,15 @@
"timeoutMs": 30000
},
// Active Jev verification cascade. Confident local judgments act immediately;
// ambiguous and protected findings defer to the reasoning verifier.
"jev": {
"model": "jev-1.13.0",
"minConfidence": 0.9,
"timeoutMs": 10000,
"maxContextChars": 30000
},
// Large diffs are split into focused chunks by changed-line count, plus a
// cross-cutting pass for multi-file issues. Diffs under maxChangedLines are one
// full-context pass. Defaults shown; raise/lower per your model + PR sizes.
File renamed without changes.
File renamed without changes.
+2
View File
@@ -90,5 +90,7 @@ jobs:
CLAUDE_CODE_REVIEW_SHARED_API_TOKEN: ${{ secrets.CLAUDE_CODE_REVIEW_SHARED_API_TOKEN }}
# Search-only credential used for fixed, site-scoped documentation discovery.
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
# Shadow-only Jev evaluator; unused while config.jev.enabled is false.
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
# Optional: override the model for every agent.
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
+1
View File
@@ -9,3 +9,4 @@ build/
# Claude Code local worktrees
.claude/worktrees/
.expo-agents/code-review/.runs/
+62 -25
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
@@ -280,6 +283,35 @@ The boundary, in brief:
Full detail — providers, query grammar, `fetch_platform_doc` modes, provenance and
citation grounding: [LLP 0013](./llp/0013-platform-research.explainer.md).
## Jev selective verification
ECR can use [TypeSafe AI's Jev](https://docs.typesafe.ai/) as the fast first stage of
finding verification. Generative agents still discover and explain problems. Jev
answers one narrow typed question against bounded local source, then either keeps a
clearly supported finding, drops a clearly contradicted ordinary finding, or defers
to the existing reasoning verifier.
There is no shadow mode or separate `enabled` flag. Add the root-only block and provide
`TYPESAFE_API_KEY`; removing the block restores the original verification path:
```jsonc
{
"jev": {
"model": "jev-1.13.0",
"minConfidence": 0.9,
"timeoutMs": 10000,
"maxContextChars": 30000
}
}
```
Each request contains one finding and bounded source from its file. Critical, security,
secrets, and cited findings are never dropped by Jev alone. Low confidence, missing
context, credentials, timeouts, and provider errors fall back to the reasoning or
original verification path. Confidence is distribution concentration, not proof, so
the model and threshold are explicit. See [LLP 0014](./llp/0014-jev-selective-verification.explainer.md)
for the research, decision table, and threat model.
## Monorepos (routing manifest)
A monorepo can route different subtrees to different reviewer rosters from a single
@@ -291,18 +323,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,7 +354,7 @@ 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",
@@ -352,9 +384,9 @@ your-monorepo/
Enforced in code and by an independent CI guard step, not by convention:
- **auth and research are locked to the root.** `tokenEnv` is honored only in the
- **auth, research, and Jev are locked to the root.** `tokenEnv` is honored only in the
root `config.jsonc` / `routing.jsonc` `defaults.auth`; a scope config declaring
`auth`/`breakGlass`/`research` fails to parse, and the CI guard sweeps every
`auth`/`breakGlass`/`research`/`jev` fails to parse, and the CI guard sweeps every
config file repo-wide and refuses to run unless `tokenEnv` appears exactly once,
root-owned, equal to `ECR_EXPECTED_TOKEN_ENV`. Routing globs choose *which
roster* reviews a file, never *which secret* is sent.
@@ -378,8 +410,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 +428,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 +458,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 +484,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)
@@ -480,7 +512,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`.
@@ -718,7 +750,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 +759,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 +792,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
+3
View File
@@ -7,6 +7,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
"@opencode-ai/sdk": "1.18.4",
"@typesafe-ai/sdk": "0.6.0",
"cheerio": "1.1.2",
"minisearch": "7.2.0",
"opencode-ai": "1.18.4",
@@ -109,6 +110,8 @@
"@types/node": ["@types/[email protected]", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-DO+2/jZinXfROG7j7WKFn/3C6nFwxy2lLpgLjEXJz+0XKphZlTLJ14mo8Vfg8X5BWN6XjyESXq+LcYdT7tR3bA=="],
"@typesafe-ai/sdk": ["@typesafe-ai/[email protected]", "", {}, "sha512-IddX+Q0XM+VagOUZFeP7wZjaO4SHMdvnh2zEBdrZZnXedWI3BNK1lKhMx3ayrkFWvVLbVcUHJy6AVZlY+e6Jaw=="],
"accepts": ["[email protected]", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
"ajv": ["[email protected]", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
+3 -1
View File
@@ -6,7 +6,7 @@
**Author:** Philippe Loulidi / Claude
**Date:** 2026-07-30
**Role:** Root
**Related:** [LLP 0001](0001-trust-model.principles.md), [LLP 0002](0002-review-engine-pipeline.explainer.md), [LLP 0003](0003-model-runtimes-and-credentials.explainer.md), [LLP 0004](0004-diff-noise-and-prompts.explainer.md), [LLP 0005](0005-verification-fingerprints-rendering.explainer.md), [LLP 0006](0006-config-schema-loading-routing.explainer.md), [LLP 0007](0007-cli-commands-and-ci.explainer.md), [LLP 0008](0008-sources-and-reporters.explainer.md), [LLP 0009](0009-adoption-templates-and-ci-workflows.guide.md), [LLP 0013](0013-platform-research.explainer.md)
**Related:** [LLP 0001](0001-trust-model.principles.md), [LLP 0002](0002-review-engine-pipeline.explainer.md), [LLP 0003](0003-model-runtimes-and-credentials.explainer.md), [LLP 0004](0004-diff-noise-and-prompts.explainer.md), [LLP 0005](0005-verification-fingerprints-rendering.explainer.md), [LLP 0006](0006-config-schema-loading-routing.explainer.md), [LLP 0007](0007-cli-commands-and-ci.explainer.md), [LLP 0008](0008-sources-and-reporters.explainer.md), [LLP 0009](0009-adoption-templates-and-ci-workflows.guide.md), [LLP 0013](0013-platform-research.explainer.md), [LLP 0014](0014-jev-selective-verification.explainer.md)
This is the root map for `@expo/code-review-cli` (`ecr`). It records what the system does, how the subsystems divide, the invariants that hold across all of them, and where each concern's rationale lives. It is intentionally thin: every subsystem's detail belongs in the doc that owns it. Read this first, then follow the link for the part you care about.
@@ -29,6 +29,7 @@ The code divides by concern. Each directory maps to the doc that owns its ration
- `src/sources/` (`local-git`, `github-pr`) + `src/reporters/` (`terminal`, `github`) — where the diff comes from and where findings go [observed: AGENTS.md:14-15; src/sources/, src/reporters/]. Owned by [LLP 0008](0008-sources-and-reporters.explainer.md).
- `templates/` + the scaffolded CI workflow — the files `ecr init` writes into an adopting repo; they define adopter-facing behavior and must stay in sync with the code [observed: AGENTS.md:19-20]. Owned by [LLP 0009](0009-adoption-templates-and-ci-workflows.guide.md).
- `src/research-mcp/` + `research/` — the bundled read-only documentation MCP, scoped remote discovery and allowlisted fetch adapters, optional index builder, source catalog, and host-side evidence prepass. Owned by [LLP 0013](0013-platform-research.explainer.md).
- `src/core/jev.ts` — optional typed first stage of finding verification against bounded local source. Owned by [LLP 0014](0014-jev-selective-verification.explainer.md).
- The trust model spans all of the above and is stated as principles in [LLP 0001](0001-trust-model.principles.md).
## Cross-Cutting Invariants
@@ -70,5 +71,6 @@ Start here, then follow the owning doc.
| What do the CLI commands do, and how does CI orchestrate them? | [LLP 0007](0007-cli-commands-and-ci.explainer.md) | `src/cli.ts`, `src/commands/*` |
| Where does the diff come from and where do findings go? | [LLP 0008](0008-sources-and-reporters.explainer.md) | `src/sources/*`, `src/reporters/*` |
| What does `ecr init` scaffold into an adopting repo? | [LLP 0009](0009-adoption-templates-and-ci-workflows.guide.md) | `templates/`, scaffolded CI workflow |
| How does Jev make narrow decisions and defer uncertain findings? | [LLP 0014](0014-jev-selective-verification.explainer.md) | `src/core/jev.ts`, `src/core/verify.ts` |
Non-doc sources of truth: `AGENTS.md` holds the working conventions (import style, error handling, the `opencode-ai`/`@opencode-ai/sdk` pinning rule, security invariants); `ROADMAP.md` lists open and deferred items — incremental review, inline PR comments, result-level caching, and a Codex token rotator among them are still open and must not be assumed implemented [observed: ROADMAP.md]; and `templates/` is the adopter-facing contract, so treat it as behavior, not sample text.
+2 -2
View File
@@ -11,7 +11,7 @@
## Pipeline Stages
`runReview()` runs a fixed sequence: filter noise → resolve the read root (the trust boundary) → start the engines → preflight that every model id resolves → chunk the diff → run agent×chunk passes plus one cross-cutting pass through a growable bounded queue → coordinate → verify → suppress → reconcile the summary → log. The whole run's resource lifecycle — engine handles, auth material, cwd, and the read-root worktree — is released in a single `finally` (`review.ts:947-951`), so no partial-startup path leaks a server or a worktree.
`runReview()` runs a fixed sequence: filter noise → resolve the read root (the trust boundary) → start the engines → preflight that every model id resolves → chunk the diff → run agent×chunk passes plus one cross-cutting pass through a growable bounded queue → coordinate → verify → suppress → reconcile the summary → log. Optional Jev classification is the first stage inside verification: clear local judgments act and uncertainty defers to the reasoning verifier. [LLP 0014](0014-jev-selective-verification.explainer.md) owns that boundary. The whole run's resource lifecycle — engine handles, auth material, cwd, and the read-root worktree — is released in a single `finally` (`review.ts:947-951`), so no partial-startup path leaks a server or a worktree.
Two ordering decisions inside that lifecycle are load-bearing and easy to break by "cleaning up":
@@ -77,7 +77,7 @@ Rate-limit evidence is tracked per engine separately and surfaced with the cause
`writeRunLog()` (`src/core/log.ts`) appends one JSON line per run — cost, tokens (run-wide and per bucket), findings, coverage notes, decision — so runs are auditable and cost/latency measurable later. The record deliberately excludes PR title and body text, keeping only `baseRef`/`headRef`, to avoid persisting secrets that might appear in author-controlled text [observed] (`log.ts:13-18`). Findings may quote changed source lines, but only content the review already publishes verbatim in the PR comment.
Each successful reviewer pass may also return a bounded conclusion-only trace: up to three concrete checks and two unresolved questions, with each note capped at 240 characters. Trace parsing is deliberately fail-soft: malformed optional diagnostics are dropped while valid findings from the same response survive, so observability can never create a coverage gap. `runReview()` merges and de-duplicates valid notes per reviewer, sorts reviewer ids, caps both the stored roster and the complete decoded trace (6 KB), labels the result `unverified-model-diagnostics`, and attaches it to `CoordinatorOutput.reviewTrace` only after the decision pipeline finishes. `attachReviewTrace` strips any coordinator-authored value before attaching the engine-built trace; even a malformed injected coordinator value parses as absent rather than failing consolidation. The trace therefore never reaches coordination, verification, suppression, or decision logic. Because the GitHub comment already embeds the full coordinator output as durable state, downstream agents can recover the trace without adding visible review prose or storing raw transcripts and chain-of-thought. The same trace rides the run log for short-lived operational inspection [observed: `schema.ts` `ReviewerTraceNotesSchema`/`ReviewTraceSchema`/`ReviewerOutputSchema`; `review.ts` `mergeTraceNotes`/`buildReviewTrace`/`attachReviewTrace`; `render.ts` embeds the full review].
Each successful reviewer pass may also return a bounded conclusion-only trace: up to three concrete checks and two unresolved questions, with each note capped at 240 characters. Trace parsing is deliberately fail-soft: malformed optional diagnostics are dropped while valid findings from the same response survive, so observability can never create a coverage gap. `runReview()` merges and de-duplicates valid notes per reviewer, sorts reviewer ids, caps both the stored roster and the complete decoded trace (6 KB), labels the result `unverified-model-diagnostics`, and attaches it to `CoordinatorOutput.reviewTrace` only after the decision pipeline finishes. `attachReviewTrace` strips any coordinator-authored value before attaching the engine-built trace; even a malformed injected coordinator value parses as absent rather than failing consolidation. The trace therefore never reaches coordination, verification, suppression, or decision logic. Because the GitHub comment already embeds the full coordinator output as durable state, downstream agents can recover the trace without adding visible review prose or storing raw transcripts and chain-of-thought. The same trace rides the run log for short-lived operational inspection [observed: `schema.ts` `ReviewerTraceNotesSchema`/`ReviewTraceSchema`/`ReviewerOutputSchema`; `review.ts` `mergeTraceNotes`/`buildReviewTrace`/`attachReviewTrace`; `render.ts` embeds the full review]. Optional Jev aggregate usage is a second log sink; individual suppressions use the ordinary `verifierDropped` audit trail, and neither contains extra source text.
Observability must never break a review, so both output sinks swallow their own errors: `safeLog` wraps `writeRunLog` and discards any write error [observed] (`review.ts:1269-1275`), and `appendStepSummary` is a no-op outside Actions and swallows append failures [observed] (`step-summary.ts:9-19`). A consequence worth stating: a missing or corrupt run-log entry is therefore **not** an error signal — the log's presence or absence proves nothing about whether the review succeeded.
@@ -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
@@ -19,7 +19,8 @@ caller, [LLP 0007](0007-cli-commands-and-ci.explainer.md) and [LLP 0001](0001-tr
## Root vs Scope Config
A scope config is the root schema minus the centrally locked keys. `ScopeReviewConfigSchema`
takes `ReviewConfigSchema`, omits `auth`, `breakGlass`, and `commentTag`, and re-adds each as
takes `ReviewConfigSchema`, omits centrally owned keys including `auth`, `breakGlass`,
`commentTag`, `research`, and `jev`, and re-adds each as
`z.never().optional()` so declaring any of them is a hard parse error, not a later runtime
check [observed: `schema.ts:232-247`]. The point of failing at the Zod level is that an IDE or
`doctor` catches it before CI does [observed: `schema.ts:224-225` comment]. The remaining
@@ -84,6 +85,13 @@ commit `43b31a4` follow-up]. The actual secret values never enter this subsystem
owns the wiring and the `FORBIDDEN_TOKEN_ENVS` list, and schema/load only ever touch the env-var
NAME [observed: `AGENTS.md` security invariants; `src/core/auth.ts:28,45,206`].
`jev` is root-only for the same trust reason as research: adding it sends bounded
repository-derived text to another provider. A scope may neither activate it nor alter
its model, threshold, or limits. Nested scopes inherit the root's resolved settings, so
one PR has one centrally owned disclosure policy [observed: `schema.ts`
`ScopeReviewConfigSchema`; `load.ts` `loadScopeConfig`]. The data boundary and active
selective cascade live in [LLP 0014](0014-jev-selective-verification.explainer.md).
## Model Resolution
The `REVIEWER_MODEL` env override is applied on top of frontmatter/default model resolution using
@@ -184,8 +192,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
@@ -64,6 +64,15 @@ credential actually selected by trusted config. The default remains Anthropic an
`dismiss.yml` still receives no model credential [observed: `templates/config.jsonc`;
`templates/workflow.yml`, `command.yml`, and `atlantis.yml`].
**Jev is an independent optional secret.** Each review-running workflow maps
`TYPESAFE_API_KEY`; adding `jev` to trusted root config activates the selective cascade.
GitHub supplies an empty value when the repository secret is absent, so the original
verification path remains available. ECR sends bounded finding/file source directly
to TypeSafe and logs aggregate usage; the key never enters `auth.tokenEnv` or reviewer
engines. `dismiss.yml` still receives no model or Jev credential.
[LLP 0014](0014-jev-selective-verification.explainer.md) owns the disclosure and
fallback rules.
**Keep the `**/*` catch-all first in `routing.jsonc`.** Scopes match last-match-wins, so a `**/*` catch-all must come first and more specific scopes come after to override it [observed: `templates/routing.jsonc:27-31`; `AGENTS.md:17-18`]. Nothing validates the order — reversing it silently changes which config applies to already-routed files [inferred: no code checks that the `**/*` catch-all comes first, so reversing it silently changes routing].
**`scope-config.jsonc` deliberately omits `auth` and `commentTag`.** It is a *different* template from the root `config.jsonc`, not a copy. A scope config carries no `auth` block — credentials are root-only, and the loader plus the CI guard reject a scope config that declares one [observed: `templates/scope-config.jsonc:1-3`; `AGENTS.md:51`]. It also omits `commentTag`, because a scope's PR-comment marker is always derived as `<rootTag>:<scope-name>` so `ecr ci` and `ecr review --scope --post` target the same comment; declaring one is rejected by the scope schema [observed: `templates/scope-config.jsonc:23-25`]. Reusing the root `config.jsonc` template for a scope — a tempting "simplification" — would break the trust boundary (LLP 0006).
@@ -0,0 +1,156 @@
# LLP 0014: Jev Selective Verification
**Type:** Explainer
**Status:** Active
**Systems:** Engine, Config, Security, Observability, Templates
**Author:** Expo
**Date:** 2026-09-21
**Related:** [LLP 0001](0001-trust-model.principles.md), [LLP 0002](0002-review-engine-pipeline.explainer.md), [LLP 0005](0005-verification-fingerprints-rendering.explainer.md), [LLP 0006](0006-config-schema-loading-routing.explainer.md), [LLP 0009](0009-adoption-templates-and-ci-workflows.guide.md)
Jev is a discriminative model from TypeSafe AI. It answers typed questions over
provided state and returns a probability distribution; it does not browse a repo,
discover open-ended defects, explain causality, or write remediation. ECR therefore
uses Jev inside verification, after generative reviewers have produced findings.
## Decision
ECR uses an active `generate → classify → defer` cascade. Jev is neither a shadow
observer nor another general reviewer agent:
1. generative reviewers discover and explain candidate defects;
2. deterministic code confines paths and grades quoted evidence;
3. Jev makes one atomic local support judgment per finding;
4. high-confidence support keeps the finding, while high-confidence contradiction
drops only ordinary correctness/quality findings;
5. uncertainty and protected findings defer to the existing reasoning verifier.
Adding the root `jev` config activates the cascade. There is no separate `enabled`
flag and no shadow phase. If the config, credential, or a usable response is absent,
the finding follows the pre-Jev verification rules.
## Evidence Survey
The primary implementation sources are TypeSafe's [Jev announcement](https://typesafe.ai/blog/introducing-system-one-models-and-jev),
[System One guide](https://docs.typesafe.ai/concepts/how-to-build-with-system-one),
[confidence guidance](https://docs.typesafe.ai/confidence),
[confidence-routing pattern](https://docs.typesafe.ai/patterns/confidence-routing),
[citation-check cookbook](https://docs.typesafe.ai/cookbooks/citation_check),
[software-development cascade](https://docs.typesafe.ai/cookbooks/sde_cascade),
[Jev 1.13 model notes](https://docs.typesafe.ai/model-jaggedness/jev-1.13), and the
open-source [JavaScript SDK](https://github.com/typesafe-ai/typesafe-sdk-js).
The consistent vendor guidance is to keep deterministic control flow in code, ask
narrow independent questions over relevant state, act only above a risk-appropriate
confidence threshold, and route uncertain results to a stronger reasoner. Confidence
measures concentration of the answer distribution, not factual correctness. Jev is
also documented as weak on arithmetic, counting, dates, complex indirection, and
irrelevant context. These limits rule out using it as the only verifier.
The architecture also follows older work on selective prediction and abstention:
[SelectiveNet](https://proceedings.mlr.press/v97/geifman19a),
[Learning to Defer](https://proceedings.mlr.press/v119/mozannar20b.html),
[Calibrated Learning to Defer](https://proceedings.mlr.press/v162/verma22c.html), and
[Language Model Cascades](https://arxiv.org/abs/2207.10342). Their shared idea is
that a useful classifier should act on its competence region and reject or defer the
rest. [Generative Verifiers](https://openreview.net/forum?id=Ccwp4tFEtE) further
supports retaining a generative verifier for multi-step reasoning rather than treating
a discriminative score as universal proof.
Code-review-specific evidence argues for conservative authority. Research on
[LLM code-review overcorrection](https://arxiv.org/abs/2603.00539),
[security code review](https://arxiv.org/abs/2401.16310), and
[judge calibration and bias](https://aclanthology.org/2025.acl-long.808/) shows that
binary judgments can be confidently wrong and that security claims need stronger
grounding. Vendor benchmarks measure agreement with other model outputs rather than
human-adjudicated ECR findings, so they establish speed and structured behavior, not
an acceptable false-negative rate for this repository.
The useful philosophical analogy is dual-process reasoning: Jev supplies a fast,
bounded System 1 judgment; the tool-using verifier supplies slower System 2 analysis.
The relevant engineering principle is a reject option, not majority voting. Jev's
different model family adds diversity, but agreement between two models is not ground
truth and disagreement is a reason to inspect, not to average blindly.
## Active Selective Cascade
The policy is intentionally asymmetric:
| Jev result | Ordinary correctness/quality | Critical, security, secrets, or cited |
| --- | --- | --- |
| supported at or above threshold | keep | cited findings defer; other protected findings keep |
| contradicted at or above threshold | drop with an audit reason | defer to reasoning verifier |
| `needs_reasoning` or low confidence | defer to reasoning verifier | defer to reasoning verifier |
| missing response or provider failure | use original verification path | use original verification path |
“Protected” controls the dangerous direction: Jev may help retain such a finding, but
cannot suppress it alone. Cited findings always defer because Jev receives repository
source, not the audited external passage whose support must be checked.
The default `minConfidence` is `0.9`. It is a conservative operating point, not a
claim of 90% correctness. Repositories may change it explicitly, but model version,
question text, state shape, threshold, and truncation policy form one classifier and
must be reviewed together. A dated model is preferred over a moving alias.
## State and Security Boundaries
Each request includes finding category, severity, file, line, title, rationale, and
quoted evidence plus bounded source from that file. ECR sends no PR title/body,
unrelated file, agent transcript, author reply, or credential. `maxContextChars`
bounds disclosure and context rot; truncation centers on the quoted evidence where
possible. Four requests run concurrently, with a bounded timeout and one retry.
The API origin is explicit. Ambient TypeSafe URL/model variables cannot redirect the
request. SDK logging is off because bodies contain source. `TYPESAFE_API_KEY` is
captured before reviewer startup, withheld from OpenCode, and forbidden as a reviewer
provider credential. Paths remain confined to the materialized review tree before
any source is read.
Jev is not a prompt-injection detector or security boundary. The code and finding
text are attacker-controlled inputs, and model output is schema-validated before the
policy sees it. Deterministic code continues to own credential isolation, path
confinement, evidence matching, category floors, and final decision derivation.
## Failure and Observability
The cascade fails back, not closed. Missing credentials disable only Jev for that run.
An individual timeout, malformed response, or provider error sends that finding
through the original verification path. Error text is not persisted because providers
may echo source. The run log stores aggregate model, duration, token, cost, evaluated,
and failure counts; any finding dropped by Jev appears in the ordinary
`verifierDropped` audit trail with its confidence.
Published pricing for the pinned integration is $0.042 per million input tokens with
no output-token charge. Cost is recorded in the `jev-verifier` bucket. Provider limits
are not treated as local capacity guarantees.
## What Jev Replaces
Jev replaces only straightforward finding verification calls. It does not replace:
- reviewer agents that discover bugs and supply explanations;
- the coordinator that merges and prioritizes findings;
- deterministic evidence, trust, and security checks;
- the reasoning verifier for ambiguity, multi-file behavior, runtime semantics,
citations, or protected negative judgments.
This division gives Jev real authority and cost benefit without pretending that a
fast typed classifier is a complete code reviewer.
## Rejected Alternatives
**A standalone `jev.md` reviewer agent.** Jev cannot use ECR's browsing tools or emit
the finding schema's explanation and remediation. Presenting it as a peer reviewer
would hide a different and narrower capability behind the same abstraction.
**Shadow-only operation.** It adds latency and cost without improving the review. The
safe boundary comes from abstention, protected categories, and fallback behavior, not
from making every result inert.
**Replace all verification.** Local classification cannot resolve missing repository
context, multi-step execution, arithmetic, temporal behavior, or external citations.
**Use confidence as proof.** A concentrated distribution can still be wrong. The
threshold is a routing policy and protected contradictions still require reasoning.
**Send the entire diff or repository.** Unrelated context increases disclosure and
context rot. One bounded file is the smallest state that supports the local question.
+1
View File
@@ -45,6 +45,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "1.30.0",
"@opencode-ai/sdk": "1.18.4",
"@typesafe-ai/sdk": "0.6.0",
"cheerio": "1.1.2",
"minisearch": "7.2.0",
"opencode-ai": "1.18.4",
+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();
+170
View File
@@ -0,0 +1,170 @@
import { describe, expect, test } from "bun:test";
import { TypeSafeClient } from "@typesafe-ai/sdk";
import { ReviewConfigSchema, ScopeReviewConfigSchema } from "../config/schema.js";
import { FORBIDDEN_TOKEN_ENVS } from "../core/auth.js";
import {
buildJevState,
evaluateFindingsWithJev,
jevDisposition,
withoutJevCredential,
} from "../core/jev.js";
import type { Finding } from "../core/schema.js";
const finding: Finding = {
severity: "warning",
category: "correctness",
file: "src/example.ts",
line: 2,
title: "The fallback returns the wrong value",
rationale: "The changed branch returns false when the caller requires true.",
evidence: "return false",
};
const config = {
model: "jev-1.13.0",
minConfidence: 0.9,
timeoutMs: 10_000,
maxContextChars: 30_000,
};
describe("Jev config", () => {
test("is activated by presence and absent by default", () => {
expect(ReviewConfigSchema.parse({}).jev).toBeUndefined();
expect(ReviewConfigSchema.parse({ jev: {} }).jev).toEqual(config);
});
test("is root-only", () => {
expect(ScopeReviewConfigSchema.safeParse({ jev: {} }).success).toBe(false);
});
test("cannot be repurposed as a reviewer provider credential", () => {
expect(FORBIDDEN_TOKEN_ENVS.has("TYPESAFE_API_KEY")).toBe(true);
});
});
describe("evaluateFindingsWithJev", () => {
test("falls back cleanly when the key is missing", async () => {
const result = await evaluateFindingsWithJev({
config,
candidates: [{ finding, sourceContext: "return false" }],
env: {},
});
expect(result.summary.unavailable).toBe("TYPESAFE_API_KEY is not set");
expect(result.evaluations.size).toBe(0);
});
test("returns a typed atomic judgment without persisting source", async () => {
const requests: unknown[] = [];
const client = new TypeSafeClient({
apiKey: "test-key",
defaultModel: config.model,
retry: { maxRetries: 0 },
fetch: async (_input, init) => {
requests.push(JSON.parse(String(init?.body)));
return new Response(
JSON.stringify({
model: "jev-1.13.0",
answers: {
support: {
type: "choice",
choice: "supported",
confidence: 0.91,
probabilities: { supported: 0.91, needs_reasoning: 0.07, contradicted: 0.02 },
},
},
usage: { input_tokens: 123, output_tokens: 0 },
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
},
});
const result = await evaluateFindingsWithJev({
config,
candidates: [{ finding, sourceContext: "export function fallback() { return false }" }],
client,
env: {},
});
expect(requests).toHaveLength(1);
expect(result.summary.evaluated).toBe(1);
expect(result.summary.inputTokens).toBe(123);
expect([...result.evaluations.values()][0]?.support).toBe("supported");
expect(JSON.stringify(result.summary)).not.toContain("return false");
});
test("fails open per finding on provider errors", async () => {
const client = new TypeSafeClient({
apiKey: "test-key",
retry: { maxRetries: 0 },
fetch: async () => {
throw new Error("request contained sensitive source");
},
});
const result = await evaluateFindingsWithJev({
config,
candidates: [{ finding, sourceContext: "secret source" }],
client,
env: {},
});
expect(result.summary.failed).toBe(1);
expect(result.summary.unavailable).toBeUndefined();
expect(JSON.stringify(result.summary)).not.toContain("sensitive source");
});
});
describe("jevDisposition", () => {
const evaluation = (
support: "supported" | "needs_reasoning" | "contradicted",
confidence = 0.95,
) => ({
fingerprint: "fp",
model: config.model,
support,
confidence,
probabilities: { supported: 0.02, needs_reasoning: 0.03, contradicted: 0.95 },
});
test("acts on clear ordinary findings and defers uncertainty", () => {
expect(jevDisposition(finding, evaluation("supported"), 0.9)).toBe("keep");
expect(jevDisposition(finding, evaluation("contradicted"), 0.9)).toBe("drop");
expect(jevDisposition(finding, evaluation("needs_reasoning"), 0.9)).toBe("defer");
expect(jevDisposition(finding, evaluation("contradicted", 0.89), 0.9)).toBe("defer");
});
test("never drops protected or cited findings by itself", () => {
expect(
jevDisposition({ ...finding, category: "security" }, evaluation("contradicted"), 0.9),
).toBe("defer");
expect(
jevDisposition({ ...finding, severity: "critical" }, evaluation("contradicted"), 0.9),
).toBe("defer");
expect(
jevDisposition(
{ ...finding, sources: [{ title: "Docs", url: "https://example.com/docs" }] },
evaluation("supported"),
0.9,
),
).toBe("defer");
});
});
test("buildJevState bounds large source around the cited evidence", () => {
const state = buildJevState(
finding,
`before-${"x".repeat(2_000)}return false${"y".repeat(2_000)}`,
1_000,
);
expect(state.sourceContext.length).toBeLessThanOrEqual(1_000);
expect(state.sourceContext).toContain("return false");
expect(state.sourceContext).toContain("source omitted by ecr");
});
test("withoutJevCredential restores the key after hiding it from a child spawn", async () => {
const env = { TYPESAFE_API_KEY: "secret", KEEP: "visible" };
const seen = await withoutJevCredential(async () => ({ ...env }), env);
expect(seen).toEqual({ KEEP: "visible" });
expect(env.TYPESAFE_API_KEY).toBe("secret");
});
+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)", () => {
+63
View File
@@ -1,4 +1,5 @@
import { test, expect, beforeAll, afterAll } from "bun:test";
import { TypeSafeClient } from "@typesafe-ai/sdk";
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
@@ -82,6 +83,34 @@ const finding = (over: Partial<Finding>): Finding => ({
// (keep the finding). Grounded non-criticals never reach the LLM at all.
const handle = {} as OpencodeHandle;
const contradictedJev = new TypeSafeClient({
apiKey: "test-key",
retry: { maxRetries: 0 },
fetch: async () =>
new Response(
JSON.stringify({
model: "jev-1.13.0",
answers: {
support: {
type: "choice",
choice: "contradicted",
confidence: 0.97,
probabilities: { supported: 0.01, needs_reasoning: 0.02, contradicted: 0.97 },
},
},
usage: { input_tokens: 20, output_tokens: 0 },
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
});
const jevConfig = {
model: "jev-1.13.0",
minConfidence: 0.9,
timeoutMs: 10_000,
maxContextChars: 30_000,
};
test("grounded non-critical is kept without an LLM call", async () => {
const res = await verifyFindings(
handle,
@@ -92,6 +121,40 @@ test("grounded non-critical is kept without an LLM call", async () => {
expect(res.dropped).toEqual([]);
});
test("Jev actively drops a confidently contradicted ordinary finding", async () => {
const res = await verifyFindings(
handle,
[finding({ title: "contradicted", evidence: "return items[next++]!;" })],
"/",
undefined,
[],
{ config: jevConfig, client: contradictedJev },
);
expect(res.kept).toEqual([]);
expect(res.dropped[0]?.reason).toContain("Jev directly contradicted");
expect(res.jev?.evaluated).toBe(1);
});
test("Jev defers a contradicted security finding to the reasoning verifier", async () => {
const res = await verifyFindings(
handle,
[
finding({
title: "protected",
category: "security",
evidence: "return items[next++]!;",
}),
],
"/",
undefined,
[],
{ config: jevConfig, client: contradictedJev },
);
// The dummy reasoning handle fails, and the existing fail-open rule keeps it.
expect(res.kept.map((item) => item.title)).toEqual(["protected"]);
expect(res.dropped).toEqual([]);
});
test("absent evidence is ESCALATED, not hard-dropped (fail-open keeps it here)", async () => {
// The key regression guard: an imperfect/absent quote must not be a silent drop.
// With the dummy handle the escalated LLM call throws → fail-open → kept.
+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);
+43 -10
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 {
@@ -82,7 +112,7 @@ export interface LoadConfigOptions {
/** Parsed config with the centrally-locked keys optional (scope configs omit them). */
type ParsedConfig = Omit<
RawReviewConfig,
"auth" | "breakGlass" | "commentTag" | "stack" | "feedback" | "research" | "inline"
"auth" | "breakGlass" | "commentTag" | "stack" | "feedback" | "research" | "jev" | "inline"
> & {
auth?: RawReviewConfig["auth"];
breakGlass?: RawReviewConfig["breakGlass"];
@@ -90,6 +120,7 @@ type ParsedConfig = Omit<
stack?: RawReviewConfig["stack"];
feedback?: RawReviewConfig["feedback"];
research?: RawReviewConfig["research"];
jev?: RawReviewConfig["jev"];
inline?: RawReviewConfig["inline"];
};
@@ -210,6 +241,7 @@ async function loadConfigDir(
// Root-only: scope schemas reject research configuration, so an untrusted
// subtree cannot select the index or alter the network-facing runtime.
research: parsed.research ?? RESEARCH_CONFIG_DEFAULTS,
...(parsed.jev ? { jev: parsed.jev } : {}),
// parsed.breakGlass/auth are always present for the root schema (defaults) and
// absent for the scope schema; loadScopeConfig overrides both afterwards.
breakGlassMarker: parsed.breakGlass?.marker ?? "/skip-review",
@@ -320,7 +352,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 +386,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
@@ -397,6 +429,7 @@ export async function loadScopeConfig(
feedback: rootConfig.feedback,
inline: rootConfig.inline,
research: rootConfig.research,
jev: rootConfig.jev,
scopeName: scope.name,
};
}
+28 -1
View File
@@ -87,6 +87,19 @@ export const ReviewConfigSchema = z.object({
resultsPerQuery: 2,
timeoutMs: 30_000,
}),
// Optional Jev verification cascade. Presence enables it: confident local
// judgments act immediately, while ambiguous or protected findings defer to
// the reasoning verifier. ROOT-ONLY because it sends bounded source context
// to a separate provider.
// @ref LLP 0014#active-selective-cascade [implements] — narrow decisions act; uncertainty defers
jev: z
.object({
model: z.string().min(1).default("jev-1.13.0"),
minConfidence: z.number().min(0.5).max(1).default(0.9),
timeoutMs: z.number().int().min(1000).max(60_000).default(10_000),
maxContextChars: z.number().int().min(1000).max(30_000).default(30_000),
})
.optional(),
breakGlass: z
.object({ marker: z.string().default("/skip-review") })
.default({ marker: "/skip-review" }),
@@ -334,7 +347,7 @@ export type RoutingDefaults = RoutingManifest["defaults"];
* Scope config = root config MINUS the centrally locked keys. Allowlist of
* scope-overridable keys (Turborepo-style, graft 6): model, policy, chunk,
* noise (+ the prompt files living beside it: shared.md, coordinator.md,
* agents/). NEVER auth, breakGlass, or research — declaring one fails parsing at the
* agents/). NEVER auth, breakGlass, research, or jev — declaring one fails parsing at the
* Zod level so IDE/doctor catch it before CI. commentTag is also locked: a
* scope's comment marker is always DERIVED (`<rootTag>:<scope>`; the default
* scope keeps the root tag) so `ecr ci`'s post/clear/reconcile paths and a
@@ -349,6 +362,7 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
stack: true,
feedback: true,
research: true,
jev: true,
inline: true,
}).extend({
auth: z
@@ -379,6 +393,12 @@ export const ScopeReviewConfigSchema = ReviewConfigSchema.omit({
"research is locked to the root config because it starts a trusted host process; remove it from this scope config",
})
.optional(),
jev: z
.never({
error:
"jev is locked to the root config because it sends bounded source context to an external provider; remove it from this scope config",
})
.optional(),
inline: z
.never({
error:
@@ -462,6 +482,13 @@ export interface LoadedConfig {
resultsPerQuery: number;
timeoutMs: number;
};
/** Root-only Jev selective-verification settings; absent means disabled. */
jev?: {
model: string;
minConfidence: number;
timeoutMs: number;
maxContextChars: number;
};
breakGlassMarker: string;
commentTag: string;
/** Every configured provider credential (one entry for the legacy single shape). */
+1
View File
@@ -50,6 +50,7 @@ export const FORBIDDEN_TOKEN_ENVS = new Set([
"GITHUB_TOKEN",
"GH_TOKEN",
"BRAVE_SEARCH_API_KEY",
"TYPESAFE_API_KEY",
"ACTIONS_RUNTIME_TOKEN",
"ACTIONS_ID_TOKEN_REQUEST_TOKEN",
"AWS_ACCESS_KEY_ID",
+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`,
});
}
}
+265
View File
@@ -0,0 +1,265 @@
// @ref LLP 0014#active-selective-cascade [implements] — Jev handles narrow judgments and defers uncertainty
import { TypeSafeClient, choice } from "@typesafe-ai/sdk";
import { z } from "zod";
import type { LoadedConfig } from "../config/schema.js";
import type { Finding } from "./schema.js";
import { fingerprintFinding } from "./schema.js";
const TYPESAFE_API_URL = "https://api.typesafe.ai";
const CONCURRENCY = 4;
const MAX_FILE_CHARS = 1_000;
const MAX_TITLE_CHARS = 1_000;
const MAX_RATIONALE_CHARS = 6_000;
const MAX_EVIDENCE_CHARS = 6_000;
const INPUT_COST_PER_MILLION = 0.042;
const SUPPORT_CRITERIA = {
supported: "The supplied source context directly demonstrates the exact reported problem.",
needs_reasoning:
"The context is insufficient, or the claim needs multi-file, runtime, arithmetic, temporal, or indirect reasoning.",
contradicted:
"The supplied source context directly demonstrates that the reported problem is not present.",
} as const;
const probability = z.number().min(0).max(1);
const JevResponseSchema = z.object({
model: z.string().min(1),
answers: z.object({
support: z.object({
type: z.literal("choice"),
choice: z.enum(["supported", "needs_reasoning", "contradicted"]),
confidence: probability,
probabilities: z.object({
supported: probability,
needs_reasoning: probability,
contradicted: probability,
}),
}),
}),
usage: z.object({
input_tokens: z.number().int().nonnegative(),
output_tokens: z.number().int().nonnegative(),
}),
});
export interface JevCandidate {
finding: Finding;
sourceContext: string;
}
export interface JevEvaluation {
fingerprint: string;
model: string;
support: keyof typeof SUPPORT_CRITERIA;
confidence: number;
probabilities: Record<keyof typeof SUPPORT_CRITERIA, number>;
}
export interface JevVerificationSummary {
configuredModel: string;
actualModel?: string;
durationMs: number;
evaluated: number;
failed: number;
inputTokens: number;
outputTokens: number;
cost: number;
unavailable?: string;
}
export interface JevVerificationResult {
evaluations: Map<string, JevEvaluation>;
summary: JevVerificationSummary;
}
export interface EvaluateWithJevOptions {
config: NonNullable<LoadedConfig["jev"]>;
candidates: readonly JevCandidate[];
env?: NodeJS.ProcessEnv;
apiKey?: string;
client?: TypeSafeClient;
}
/** Run a reviewer-engine spawn without exposing the unrelated Jev credential. */
export async function withoutJevCredential<T>(
run: () => Promise<T>,
env: NodeJS.ProcessEnv = process.env,
): Promise<T> {
const apiKey = env.TYPESAFE_API_KEY;
try {
delete env.TYPESAFE_API_KEY;
return await run();
} finally {
if (apiKey === undefined) delete env.TYPESAFE_API_KEY;
else env.TYPESAFE_API_KEY = apiKey;
}
}
/** Classify candidates with one atomic question; failures become missing evaluations. */
export async function evaluateFindingsWithJev({
config,
candidates,
env = process.env,
apiKey: explicitApiKey,
client,
}: EvaluateWithJevOptions): Promise<JevVerificationResult> {
const started = Date.now();
const apiKey = (explicitApiKey ?? env.TYPESAFE_API_KEY)?.trim();
if (!client && !apiKey) return emptyResult(config, started, "TYPESAFE_API_KEY is not set");
let resolvedClient: TypeSafeClient;
try {
resolvedClient =
client ??
new TypeSafeClient({
apiKey,
baseURL: TYPESAFE_API_URL,
defaultModel: config.model,
timeout: config.timeoutMs,
retry: { maxRetries: 1 },
logLevel: "off",
});
} catch {
return emptyResult(config, started, "Jev client could not be initialized");
}
const evaluations = new Map<string, JevEvaluation>();
let failed = 0;
let inputTokens = 0;
let outputTokens = 0;
let actualModel: string | undefined;
let next = 0;
const worker = async (): Promise<void> => {
while (next < candidates.length) {
const candidate = candidates[next++];
if (!candidate) continue;
try {
const response = JevResponseSchema.parse(
await resolvedClient.systemOne({
model: config.model,
state: buildJevState(
candidate.finding,
candidate.sourceContext,
config.maxContextChars,
),
questions: {
support: choice(
"Does this source context directly support or contradict this exact code-review finding? Select needs_reasoning whenever the answer depends on omitted context or non-local reasoning.",
SUPPORT_CRITERIA,
),
},
}),
);
inputTokens += response.usage.input_tokens;
outputTokens += response.usage.output_tokens;
actualModel = response.model;
const fingerprint = fingerprintFinding(candidate.finding);
evaluations.set(fingerprint, {
fingerprint,
model: response.model,
support: response.answers.support.choice,
confidence: response.answers.support.confidence,
probabilities: response.answers.support.probabilities,
});
} catch {
failed += 1;
}
}
};
await Promise.all(
Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, () => worker()),
);
return {
evaluations,
summary: {
configuredModel: config.model,
...(actualModel ? { actualModel } : {}),
durationMs: Date.now() - started,
evaluated: evaluations.size,
failed,
inputTokens,
outputTokens,
cost: (inputTokens * INPUT_COST_PER_MILLION) / 1_000_000,
},
};
}
/** High-confidence local answers act; sensitive or uncertain answers defer. */
export function jevDisposition(
finding: Finding,
evaluation: JevEvaluation | undefined,
minConfidence: number,
): "keep" | "drop" | "defer" {
if (!evaluation || evaluation.confidence < minConfidence) return "defer";
// Jev sees repository source, not the audited external passage. The reasoning
// verifier remains responsible for citation support in either direction.
if (finding.sources?.length) return "defer";
if (evaluation.support === "supported") return "keep";
if (evaluation.support === "needs_reasoning") return "defer";
if (
finding.severity === "critical" ||
finding.category === "security" ||
finding.category === "secrets"
) {
return "defer";
}
return "drop";
}
/** Build the only state sent to TypeSafe: finding metadata plus bounded local source. */
export function buildJevState(finding: Finding, sourceContext: string, maxContextChars: number) {
return {
finding: {
severity: finding.severity,
category: finding.category,
file: boundText(finding.file, MAX_FILE_CHARS),
line: finding.line ?? null,
title: boundText(finding.title, MAX_TITLE_CHARS),
rationale: boundText(finding.rationale, MAX_RATIONALE_CHARS),
evidence: finding.evidence ? boundText(finding.evidence, MAX_EVIDENCE_CHARS) : null,
},
sourceContext: boundContext(sourceContext, finding.evidence, maxContextChars),
};
}
function boundText(value: string, maxChars: number): string {
return value.length <= maxChars ? value : `${value.slice(0, maxChars - 1)}…`;
}
function boundContext(context: string, evidence: string | undefined, maxChars: number): string {
if (context.length <= maxChars) return context;
const omitted = "\n... source omitted by ecr ...\n";
const budget = Math.max(1, maxChars - omitted.length);
const evidenceAt = evidence ? context.indexOf(evidence) : -1;
if (evidenceAt >= 0) {
const start = Math.max(0, evidenceAt - Math.floor(budget / 2));
return context.slice(start, start + budget) + omitted;
}
const first = Math.floor(budget / 2);
const last = budget - first;
return context.slice(0, first) + omitted + context.slice(-last);
}
function emptyResult(
config: NonNullable<LoadedConfig["jev"]>,
started: number,
unavailable: string,
): JevVerificationResult {
return {
evaluations: new Map(),
summary: {
configuredModel: config.model,
durationMs: Date.now() - started,
evaluated: 0,
failed: 0,
inputTokens: 0,
outputTokens: 0,
cost: 0,
unavailable,
},
};
}
+3
View File
@@ -6,6 +6,7 @@ import type { CoordinatorOutput, Finding, ReviewMetadata, ReviewTrace } from "./
import type { FilteredFile } from "./noise.js";
import type { TokenUsage } from "./opencode.js";
import type { ResearchProvenance } from "./research.js";
import type { JevVerificationSummary } from "./jev.js";
export interface RunLogRecord {
timestamp: string;
@@ -21,6 +22,8 @@ export interface RunLogRecord {
filteredFiles: FilteredFile[];
/** Bounded queries/results plus grounded research decisions and final usefulness metrics. */
research?: ResearchProvenance;
/** Active Jev cascade usage and availability; individual drops use verifierDropped. */
jevVerification?: JevVerificationSummary;
agentCosts: Record<string, number>;
totalCost: number;
// Aggregate token usage across all agent + coordinator requests, for cache
+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;
}
+25 -1
View File
@@ -84,6 +84,8 @@ import {
summarizeResearchUsefulness,
} from "./research.js";
import type { ResearchEvidence, ResearchMcpRuntime, ResearchProvenance } from "./research.js";
import { withoutJevCredential } from "./jev.js";
import type { JevVerificationSummary } from "./jev.js";
export interface ReviewRunOptions {
config: LoadedConfig;
@@ -297,6 +299,7 @@ export async function runReview(
let researchEvidence: ResearchEvidence[] = [];
let researchRecord: ResearchProvenance | undefined;
let jevRecord: JevVerificationSummary | undefined;
// Materialize the PR-head tree (not the current checkout) when the source can, so
// the agents' surrounding-source reads and the verifier's re-reads see the versions
@@ -387,9 +390,16 @@ export async function runReview(
// close it. Two separate try blocks keep the precise per-engine error messages.
let opencodeHandle: OpencodeHandle | null = null;
let claudeHandle: ClaudeCodeHandle | null = null;
// OpenCode's SDK copies process.env into its server. Withhold the unrelated Jev
// credential at spawn time, then restore the parent immediately; Claude uses an
// allowlisted child env already. The captured value is passed directly to Jev.
// @ref LLP 0014#active-selective-cascade [constrained-by] — reviewer engines never receive the Jev credential
const jevApiKey = process.env.TYPESAFE_API_KEY;
try {
if (usesOpencode) {
opencodeHandle = await startOpencode(buildOpencodeConfig(config, researchRuntime));
opencodeHandle = await withoutJevCredential(() =>
startOpencode(buildOpencodeConfig(config, researchRuntime)),
);
}
} catch (error) {
await auth.cleanup();
@@ -1089,6 +1099,9 @@ export async function runReview(
process.cwd(),
progress,
researchEvidence,
config.jev
? { config: config.jev, ...(jevApiKey ? { apiKey: jevApiKey } : {}) }
: undefined,
);
agentCosts["verifier"] = verification.cost;
trackTokens("verifier", verification.tokens);
@@ -1098,6 +1111,15 @@ export async function runReview(
config.agents[0]?.model ?? config.coordinator.model,
verification.model,
);
if (verification.jev) {
jevRecord = verification.jev;
agentCosts["jev-verifier"] = verification.jev.cost;
trackTokens("jev-verifier", {
input: verification.jev.inputTokens,
output: verification.jev.outputTokens,
});
trackModel("jev-verifier", verification.jev.configuredModel, verification.jev.actualModel);
}
verifierDropped = verification.dropped;
citationStrips = verification.citationStripped;
if (verification.dropped.length > 0 || verification.citationStripped.length > 0) {
@@ -1370,6 +1392,7 @@ export async function runReview(
await safeLog(logPath, {
...baseRecord,
...(researchRecord ? { research: researchRecord } : {}),
...(jevRecord ? { jevVerification: jevRecord } : {}),
agentCosts,
totalCost: sum(agentCosts),
tokens: tokenTotals,
@@ -1408,6 +1431,7 @@ export async function runReview(
await safeLog(logPath, {
...baseRecord,
...(researchRecord ? { research: researchRecord } : {}),
...(jevRecord ? { jevVerification: jevRecord } : {}),
agentCosts,
totalCost: sum(agentCosts),
tokens: tokenTotals,
+75 -10
View File
@@ -2,11 +2,15 @@
// @ref LLP 0005#verifier-confinement-and-fail-open
import { readFile } from "node:fs/promises";
import path from "node:path";
import type { TypeSafeClient } from "@typesafe-ai/sdk";
import { pathInside } from "./exec.js";
import type { LoadedConfig } from "../config/schema.js";
import { evaluateFindingsWithJev, jevDisposition } from "./jev.js";
import type { JevVerificationSummary } from "./jev.js";
import type { ResearchEvidence } from "./research.js";
import type { Finding } from "./schema.js";
import { parseVerdict } from "./schema.js";
import { fingerprintFinding, parseVerdict } from "./schema.js";
import { addTokenUsage, promptAndParse, VERIFIER_AGENT } from "./opencode.js";
import type { OpencodeHandle, TokenUsage } from "./opencode.js";
import { buildVerifierSystem, buildVerifierTask } from "./prompts.js";
@@ -34,6 +38,8 @@ export interface VerificationResult {
tokens: TokenUsage;
/** provider/model that actually answered the verify calls (see PromptResult.model). */
model?: string;
/** Active selective-verification usage; absent when Jev is not configured. */
jev?: JevVerificationSummary;
}
/** The audited passages behind a finding's grounded citations, bounded per source. */
@@ -94,10 +100,13 @@ export function matchEvidence(evidence: string, content: string): "present" | "a
// @ref LLP 0005#verifier-confinement-and-fail-open [implements] — pathInside gate: out-of-tree reads (and their present/absent verdict) refused
/** Read the cited file and grade the evidence against it (see matchEvidence). */
async function evidencePresence(
async function inspectEvidence(
finding: Finding,
cwd: string,
): Promise<"present" | "absent" | "unknown"> {
): Promise<{
presence: "present" | "absent" | "unknown";
sourceContext: string;
}> {
// finding.file is an unconstrained, LLM-authored string produced over untrusted PR
// content, so a prompt-injected finding could point it at a host secret. path.resolve
// IGNORES cwd when finding.file is already absolute (e.g. ~/.claude/.credentials.json),
@@ -107,15 +116,15 @@ async function evidencePresence(
// materialized PR-head tree); anything outside is uncheckable, never read.
const resolved = path.resolve(cwd, finding.file);
if (!pathInside(resolved, cwd)) {
return "unknown";
return { presence: "unknown", sourceContext: "" };
}
let content: string;
try {
content = await readFile(resolved, "utf8");
} catch {
return "unknown";
return { presence: "unknown", sourceContext: "" };
}
return matchEvidence(finding.evidence ?? "", content);
return { presence: matchEvidence(finding.evidence ?? "", content), sourceContext: content };
}
// @ref LLP 0005#verifier-confinement-and-fail-open [constrained-by] — fails open: a verify error/timeout keeps the finding, never drops it
@@ -145,6 +154,12 @@ export async function verifyFindings(
onProgress?: (message: string) => void,
/** This run's audited research evidence, for findings that cite documentation. */
researchEvidence: ResearchEvidence[] = [],
jev?: {
config: NonNullable<LoadedConfig["jev"]>;
apiKey?: string;
/** Test seam; production constructs the pinned client inside jev.ts. */
client?: TypeSafeClient;
},
): Promise<VerificationResult> {
const dropped: Array<{ finding: Finding; reason: string }> = [];
const citationStripped: Array<{ finding: Finding; reason: string }> = [];
@@ -156,9 +171,29 @@ export async function verifyFindings(
// Phase 1 — deterministic quote-grounding for every finding.
const checked = await Promise.all(
findings.map(async (finding) => ({ finding, presence: await evidencePresence(finding, cwd) })),
findings.map(async (finding) => ({ finding, ...(await inspectEvidence(finding, cwd)) })),
);
// Phase 2 — active selective classification. A confident local answer can
// avoid the slower verifier; uncertainty is explicitly handed to it.
const jevResult = jev
? await evaluateFindingsWithJev({
config: jev.config,
candidates: checked.map(({ finding, sourceContext }) => ({ finding, sourceContext })),
...(jev.apiKey ? { apiKey: jev.apiKey } : {}),
...(jev.client ? { client: jev.client } : {}),
})
: undefined;
if (jevResult?.summary.unavailable) {
onProgress?.(
` Jev unavailable (${jevResult.summary.unavailable}); using normal verification.`,
);
} else if (jevResult) {
onProgress?.(
` Jev classified ${jevResult.summary.evaluated} finding(s); ${jevResult.summary.failed} fell back.`,
);
}
// Decide which findings need an LLM check vs. can be kept directly. A finding
// that cites documentation always gets an LLM check: the repo alone cannot
// confirm an external-behavior claim, and the verifier must judge whether the
@@ -171,14 +206,36 @@ export async function verifyFindings(
}> = [];
for (const { finding, presence } of checked) {
const citedSources = citedSourcesFor(finding, researchEvidence);
if (presence === "absent" || finding.severity === "critical" || citedSources) {
const evaluation = jevResult?.evaluations.get(fingerprintFinding(finding));
const disposition = evaluation
? jevDisposition(finding, evaluation, jev?.config.minConfidence ?? 1)
: undefined;
if (disposition === "keep") {
verdicts.set(finding, "keep");
continue;
}
if (disposition === "drop") {
verdicts.set(finding, "drop");
dropped.push({
finding,
reason: `Jev directly contradicted the finding (${Math.round(evaluation!.confidence * 100)}% confidence)`,
});
onProgress?.(` Jev: dropped ${finding.severity} "${finding.title}" as contradicted.`);
continue;
}
if (
disposition === "defer" ||
presence === "absent" ||
finding.severity === "critical" ||
citedSources
) {
toVerify.push({ finding, presence, ...(citedSources ? { citedSources } : {}) });
} else {
verdicts.set(finding, "keep"); // grounded (or uncheckable) non-critical
}
}
// Phase 2 — LLM verify (parallel). Refuted → drop; verified or errored → keep.
// Phase 3 — reasoning verify (parallel). Refuted → drop; verified or errored → keep.
await Promise.all(
toVerify.map(async ({ finding, presence, citedSources }, index) => {
try {
@@ -242,5 +299,13 @@ export async function verifyFindings(
const kept = findings
.filter((finding) => verdicts.get(finding) === "keep")
.map((finding) => replacements.get(finding) ?? finding);
return { kept, dropped, citationStripped, cost, tokens, model };
return {
kept,
dropped,
citationStripped,
cost,
tokens,
model,
...(jevResult ? { jev: jevResult.summary } : {}),
};
}
+5 -1
View File
@@ -114,6 +114,8 @@ jobs:
# META_API_KEY: ${{ secrets.META_API_KEY }}
# Optional search-only credential for trusted platform documentation research.
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
# Optional Jev verification credential (used when the root config has `jev`).
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
# Optional: override the model for every agent.
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
run: |
@@ -128,6 +130,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
+5 -1
View File
@@ -152,6 +152,8 @@ jobs:
# META_API_KEY: ${{ secrets.META_API_KEY }}
# Optional search-only credential for trusted platform documentation research.
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
# Optional Jev verification credential (used when the root config has `jev`).
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
# Optional: override the model for every agent.
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
AGENTS: ${{ steps.cmd.outputs.agents }}
@@ -183,6 +185,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
+13 -1
View File
@@ -48,6 +48,18 @@
// "timeoutMs": 30000
// },
// Optional Jev verification cascade (ROOT-ONLY). Adding this block activates it:
// confident local support keeps a finding, confident contradiction drops an
// ordinary finding, and uncertainty or protected findings defer to the reasoning
// verifier. Pin the model because moving aliases can invalidate the threshold.
// Set TYPESAFE_API_KEY in the environment before adding the block.
// "jev": {
// "model": "jev-1.13.0",
// "minConfidence": 0.9,
// "timeoutMs": 10000,
// "maxContextChars": 30000
// },
// Large diffs are split into focused chunks by changed-line count, plus a
// cross-cutting pass for multi-file issues. Diffs under maxChangedLines are one
// full-context pass. Defaults shown; raise/lower per your model + PR sizes.
@@ -87,7 +99,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" }
// } }
+5 -1
View File
@@ -118,6 +118,8 @@ jobs:
# META_API_KEY: ${{ secrets.META_API_KEY }}
# Optional search-only credential for trusted platform documentation research.
BRAVE_SEARCH_API_KEY: ${{ secrets.BRAVE_SEARCH_API_KEY }}
# Optional Jev verification credential (used when the root config has `jev`).
TYPESAFE_API_KEY: ${{ secrets.TYPESAFE_API_KEY }}
# Optional: override the model for every agent.
REVIEWER_MODEL: ${{ vars.REVIEWER_MODEL }}
@@ -133,6 +135,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