@golden-frijoles/kit 0.5.3 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,34 +1,16 @@
1
1
  // cross-agent-cli.mjs — the shared CLI plumbing for the cross-agent second-opinion tools.
2
2
  //
3
- // One source of truth for driving a DIFFERENT model family's CLI, reused by both:
3
+ // One source of truth for driving a DIFFERENT model family's CLI (Codex or Antigravity), reused by both:
4
4
  // • scripts/cross-review.mjs — advisory second opinion on a PR diff
5
5
  // • scripts/cross-panel.mjs — advisory second opinion on a proposed plan (a scope/seed doc)
6
6
  //
7
7
  // It holds only the family-agnostic mechanics: presence/version checks, the per-CLI context-passing quirks
8
- // (each CLI takes its context differently — see the table below), and the shared-prompt loader. The
9
- // *framing* of the context (a PR diff vs a plan doc) and the output handling (post a PR comment vs print a
10
- // panel) stay in each consuming script. Zero npm deps — Node 18+.
11
- //
12
- // ── The reviewer roster, and how each one takes its context ──────────────────────────────────────────────
13
- // FOUR families are wired here. They are all different from each other, which is the entire point: a
14
- // second family re-derives the diff and catches what the builder's context-bias hides.
15
- //
16
- // | --agent | CLI | Family | Context passing | Read-only by |
17
- // |---------------|----------|-------------------|--------------------------------------|------------------|
18
- // | codex | `codex` | GPT (OpenAI) | stdin (`codex exec <prompt>`) | no repo writes |
19
- // | antigravity | `agy` | Gemini → GPT-OSS | argv `-p` (256 KB cap; stdin at EOF) | print mode |
20
- // | vibe | `vibe` | Mistral | argv `--prompt` (256 KB cap) | `--agent plan` |
21
- // | claude | `claude` | Anthropic | stdin (`claude -p <prompt>`) | `--tools ""` |
22
- //
23
- // **`claude` is here so that a NON-Claude orchestrator can use it.** When Codex or Antigravity is driving
24
- // a build, "run a cross-family review" previously meant reaching for a Claude *subagent*, which only a
25
- // Claude host can spawn. Exposing Claude Code as a plain CLI reviewer on this table makes the review layer
26
- // symmetric: any orchestrator, from any family, can route to any other family through one script. The
27
- // routing policy itself (who reviews whom, and how many) lives in `scripts/review-route.mjs`, not here —
28
- // this module only knows how to *drive* each CLI.
8
+ // (codex takes context on stdin; agy takes the prompt as the `-p` argv value — stdin is NOT the prompt and
9
+ // must be at EOF — with a size cap), and the shared-prompt loader. The *framing* of the context (a PR diff
10
+ // vs a plan doc) and the output handling
11
+ // (post a PR comment vs print a panel) stay in each consuming script. Zero npm deps — Node 18+.
29
12
  //
30
13
  // ── Codex → Antigravity auto-fallback ───────────────────────────────────────────────────────────────────
31
- //
32
14
  // When the local Codex token has lapsed, `codex exec` exits non-zero with an AUTH error on stderr (e.g.
33
15
  // "Your session has ended. Please log in again." / "refresh token was revoked" / 401). `runWithCodexFallback`
34
16
  // detects that specific auth signal (NOT every error — a non-auth break or an empty diff still fails clearly)
@@ -49,10 +31,15 @@ export const AGENTS = {
49
31
  claude: 'Claude Code',
50
32
  };
51
33
 
52
- // The binary each --agent value dispatches to. Kept beside AGENTS so a presence check
53
- // (`hasCmd(AGENT_BIN[agent])`) never has to re-derive the mapping — `antigravity` → `agy` is the one
54
- // place where the flag value and the executable name genuinely differ.
55
- export const AGENT_BIN = { codex: 'codex', antigravity: 'agy', vibe: 'vibe', claude: 'claude' };
34
+ // The binary each --agent value dispatches to — `antigravity` → `agy` is the one place the flag
35
+ // value and the executable name genuinely differ.
36
+ export const AGENT_BIN = {
37
+ codex: 'codex',
38
+ antigravity: 'agy',
39
+ devin: 'devin',
40
+ vibe: 'vibe',
41
+ claude: 'claude',
42
+ };
56
43
 
57
44
  // Antigravity's headless CLI is new and its print contract shifts between releases — pin the known-good
58
45
  // version and FAIL LOUD on a mismatch (checkAgyVersion). 1.0.7→1.0.10 silently changed `--print` so it
@@ -66,10 +53,10 @@ export const AGENT_BIN = { codex: 'codex', antigravity: 'agy', vibe: 'vibe', cla
66
53
  // Harmless here since AGY_MODEL/AGY_FALLBACK_MODEL below are always valid, listed model names (checked via
67
54
  // `agy models`), but it means a future typo in either constant would silently review with the WRONG model
68
55
  // instead of failing loud — watch for that if either constant is ever edited.
69
- // agy-doctor: last verified 2026-09-17 against 1.2.5.
70
- // ^ machine-managed marker — `node scripts/agy-doctor.mjs --fix` rewrites it (with the constant
56
+ // agy-doctor: last verified 2026-09-29 against 1.2.13.
57
+ // ^ machine-managed marker — `node scripts/cross-agent-doctor.mjs agy --fix` rewrites it (with the constant
71
58
  // below) after a green live contract probe. Don't hand-edit the marker's shape.
72
- export const AGY_PINNED = '1.2.5';
59
+ export const AGY_PINNED = '1.2.13';
73
60
 
74
61
  // agy's `--print` mode prints NOTHING unless `--model` names a model — and, crucially, it ALSO prints
75
62
  // nothing (exit 0, empty stdout — the error lands only in agy's log, see --log-file) when the model is
@@ -78,74 +65,265 @@ export const AGY_PINNED = '1.2.5';
78
65
  // ("RESOURCE_EXHAUSTED 429: Individual quota reached"), so runAntigravity AUTO-FALLS-BACK to
79
66
  // AGY_FALLBACK_MODEL (GPT-OSS, a separate quota pool that worked on the dev machine) when the primary yields
80
67
  // empty. Override either via env.
81
- // Roster note (probed, never assumed — `agy models`): 1.2.x takes SLUGS, not display names, and generation
82
- // and TIER are independent. 3.6 is the newest generation but ships FLASH-only (`gemini-3.6-pro-high` does
83
- // not exist); the previous `gemini-3.1-pro-high` was the only PRO-tier Gemini, so this is a generation
84
- // upgrade and a tier downgrade at once — kept under review against real diffs, not settled by the number.
85
- export const AGY_MODEL = process.env.AGY_MODEL || 'gemini-3.6-flash-high';
86
- // Deliberately a DIFFERENT PROVIDER POOL, not merely a different model: the fallback exists so a spent
87
- // Google quota still leaves a cross-family pass alive. A second `gemini-*` here would share the pool it is
88
- // supposed to survive.
68
+ // agy 1.1.5 renamed its model identifiers from display names ("Gemini 3.1 Pro (High)") to slugs
69
+ // ("gemini-3.1-pro-high"). Same models, new naming scheme — a mechanical remap, NOT a change of
70
+ // which models review. The Gemini-family primary is what gives this gate its model-family contrast
71
+ // with Codex; the GPT-OSS fallback is GPT-lineage (so it costs that contrast) and exists only
72
+ // because it draws on a separate quota pool when Gemini is exhausted.
73
+ // The product owner's call (2026-07-26): agy REVIEWS on the **current Gemini Flash generation at HIGH effort**.
74
+ // This was `gemini-3.1-pro-high`, then `gemini-3.6-flash-high`, and is now `gemini-3.8-flash-high`.
75
+ //
76
+ // ⚠️ **A retired slug fails as a QUOTA CAP, which is the wrong diagnosis and costs a review pass.**
77
+ // On 2026-09-09 a review returned no output on the primary AND the fallback, and the CLI's advice
78
+ // was "likely a quota cap (RESOURCE_EXHAUSTED 429)" — so the honest report was going to be "agy is
79
+ // capped, the second cross-family pass is missing". The product owner's answer was one line: *"agy is not
80
+ // exhausted. the model is 3.8 flash."* `agy models` confirmed it: 3.8 is listed, 3.6 is still listed
81
+ // too, and an unavailable-but-valid slug answers with silence rather than an error.
82
+ //
83
+ // The generation moves under this constant and nothing in the repo notices, because both the old and
84
+ // the new slug parse. `cross-agent-doctor.mjs agy` validates AGY_MODELS_IN_USE against `agy models`, which
85
+ // catches a slug that has been REMOVED — it cannot catch one that is merely a generation behind and
86
+ // out of quota. Re-check this line when a review comes back empty on both models before believing
87
+ // the cap.
88
+ export const AGY_MODEL = process.env.AGY_MODEL || 'gemini-3.8-flash-high';
89
89
  export const AGY_FALLBACK_MODEL = process.env.AGY_FALLBACK_MODEL || 'gpt-oss-120b-medium';
90
90
 
91
- // agy takes the prompt+context as a single `-p` argv string (stdin is not the prompt). Guard well under the
92
- // OS limit (macOS ARG_MAX is 1 MB incl. env) so a huge input fails clearly instead of an opaque E2BIG.
93
- export const AGY_ARG_LIMIT = 256 * 1024;
91
+ // ── The PROSE pair lives HERE, not in prose-draft.mjs, and that move fixed a live silent bug ─────
92
+ // (2026-07-25.) prose-draft.mjs owned its own constants and still held agy's PRE-1.1.5 display
93
+ // names — `'Gemini 3.5 Flash (High)'` / `'GPT-OSS 120B (Medium)'`. Neither has been a valid model
94
+ // identifier since the 1.1.5 slug rename, and per the note above agy answers an unrecognized
95
+ // `--model` by silently substituting its own default and returning output anyway. So every prose
96
+ // draft produced since that rename ran on agy's default model, exit 0, no warning, plausible
97
+ // output — the exact failure the paragraph above predicted ("a future typo in either constant
98
+ // would silently review with the WRONG model instead of failing loud"). It was predicted, written
99
+ // down, and still shipped, because the prediction guarded the constants agy-doctor checks and
100
+ // these two lived somewhere it never looked.
101
+ //
102
+ // The fix is structural, not a re-typing: every agy model this repo configures is now declared in
103
+ // THIS file, and `cross-agent-doctor.mjs agy` validates AGY_MODELS_IN_USE (below) against `agy models` in one
104
+ // pass. A new consumer that wants its own pair adds it here and inherits the check for free.
105
+ //
106
+ // Flash (not Pro) for prose is deliberate and unchanged in intent: prose doesn't need Pro-tier
107
+ // reasoning and the coordinating agent is the editor. Moved 3.5 → 3.6 because 3.6 shipped and is
108
+ // listed; same tier, current generation.
109
+ // ── Prose: agy is the FALLBACK behind Devin, and when it runs it runs GPT-OSS only ─────────────
110
+ // The division of labour (the product owner, 2026-07-26): **Devin is the dedicated, specialized prose writer**,
111
+ // so agy's and Codex's quota stays free for review and building. agy is prose's fallback, and it runs
112
+ // the GPT lineage — the register the brief asked for ("an executive-level product-manager report")
113
+ // and the one both accepted drafts carried.
114
+ //
115
+ // ── Why there is NO model-level fallback here, deliberately ────────────────────────────────────
116
+ // This used to be a pair: Gemini Flash primary, GPT-OSS fallback. Two silent consequences. Every
117
+ // draft came from Gemini unless its quota happened to be exhausted — which is exactly when the two
118
+ // drafts the product owner accepted were produced, so the output they liked was the ACCIDENT and the default was
119
+ // the regression. And a fallback between two models with very different registers changed the voice
120
+ // of the report with no error, no warning, and a footer that said only "agy".
121
+ //
122
+ // So prose has ONE model. The fallback that matters is at the WRITER level (Devin → agy), and that
123
+ // one is named in the footer. A model-level fallback to a Gemini slug would also spend the review
124
+ // quota this split exists to protect. If GPT-OSS is briefly unavailable, prose fails and is re-run —
125
+ // an honest gap beats a silent change of voice.
126
+ export const PROSE_MODEL = process.env.PROSE_MODEL || 'gpt-oss-120b-medium';
94
127
 
95
- // ── Mistral Vibe (`vibe`) ────────────────────────────────────────────────────────────────────────────────
96
- // Vibe's non-interactive path is `--prompt` ("programmatic mode"): it skips the Textual chat UI, disables
97
- // interactive tools, and exits when done. Three flags matter for a reviewer and each is deliberate:
128
+ // NOTE — there are deliberately NO commit-report-specific model constants. There were two,
129
+ // COMMIT_REPORT_MODEL and COMMIT_REPORT_FALLBACK_MODEL, and NOTHING read them: `commit-report.mjs`
130
+ // routes through prose-writer → runAgy → PROSE_MODEL. So the constants that appeared to configure the
131
+ // commit report configured nothing, while their own comment correctly described the intent ("GPT
132
+ // primary").
98
133
  //
99
- // --agent plan Vibe ships several approval agents (default · plan · accept-edits · auto-approve) and
100
- // programmatic mode DEFAULTS TO `auto-approve` — i.e. a reviewer that could silently edit
101
- // the working tree. `plan` is the read-only one. This is not a nicety: an advisory review
102
- // that mutates the repo it is reviewing is the exact failure the "advisory only" contract
103
- // exists to prevent, so the flag is passed unconditionally and is not overridable by env.
104
- // --max-turns Vibe's own docs recommend bounding run length. A single-pass review needs very few.
105
- // --output text json/streaming are for structured consumers; we want the prose findings on stdout.
106
- // --trust Programmatic mode still ENFORCES folder trust but cannot show the confirmation prompt,
107
- // so an untrusted directory would otherwise stall or refuse. `--trust` grants trust for
108
- // this invocation only (it does not write ~/.vibe/trusted_folders.toml).
134
+ // That is the mechanism behind this whole incident. The documented intent said GPT, the dead constant
135
+ // said GPT, and the constant actually in force said Gemini — so the rail could be read three times
136
+ // and still misunderstood, and anyone "fixing" the register by editing COMMIT_REPORT_MODEL would have
137
+ // seen no effect at all. Deleted rather than wired up: one prose model, in one place.
138
+
139
+ // Every agy model name this repo pins, as {constant, value} — the list cross-agent-doctor walks. Keeping it
140
+ // adjacent to the declarations (rather than rebuilt in the doctor) means adding a model and
141
+ // forgetting to register it is a one-line miss in ONE file, visible in review.
142
+ export const AGY_MODELS_IN_USE = [
143
+ { constant: 'AGY_MODEL', value: AGY_MODEL },
144
+ { constant: 'AGY_FALLBACK_MODEL', value: AGY_FALLBACK_MODEL },
145
+ { constant: 'PROSE_MODEL', value: PROSE_MODEL },
146
+ ];
147
+
148
+ // ── The CODEX pair, pinned here for the same reason the agy models are ───────────────────────────
149
+ // The product owner's call (2026-07-26): codex reviews on **gpt-5.6-terra at HIGH reasoning effort**.
109
150
  //
110
- // ⚠ CONTRACT NOT YET PROBED AGAINST A LIVE BINARY. Everything above comes from Mistral's published CLI
111
- // docs, not from a run on this machine — and this repo's own rule is that a roster is PROBED, never
112
- // assumed (a CLI that self-reports a capability it does not have has burned us before; see the agy
113
- // version pin above, where a silent `--print` contract change shipped empty reviews for weeks). Before
114
- // relying on vibe in a real review, run the probe once and record the result here:
151
+ // Until now `execCodex` passed NO --model at all, so every codex review silently inherited whatever
152
+ // `~/.codex/config.toml` happened to say. That is the identical shape as the PROSE_MODEL incident
153
+ // documented above — a model choice that lives somewhere the repo never looks, changes without a
154
+ // signal, and produces plausible output either way. It is arguably worse: an agy typo at least had a
155
+ // constant in the repo to be wrong, whereas an ambient config has nothing to review at all, and the
156
+ // value differs per machine, so CI and a laptop can disagree with no way to notice.
115
157
  //
116
- // node scripts/cross-review.mjs <PR#> --agent vibe --dry-run
158
+ // Passing them explicitly makes the review's model a property of the REPO, and any future change a
159
+ // diff. Env-overridable like its agy siblings, so a quota-exhausted day needs no commit.
117
160
  //
118
- // A green probe prints real findings. An EMPTY result is a FAILURE, not a pass — see runVibe below, which
119
- // treats empty stdout as an error exactly like the agy path does, for the same reason (a quota-capped or
120
- // misconfigured CLI can exit 0 with nothing).
121
- // vibe-probe: NOT YET VERIFIED — replace this line with `vibe-probe: verified <date> against <version>`.
161
+ // Verified live 2026-07-26 against codex-cli 0.144.6: `codex exec --model gpt-5.6-terra
162
+ // -c model_reasoning_effort=high` reports `model: gpt-5.6-terra` / `reasoning effort: high` in its
163
+ // own session banner and returns real output. Reasoning effort rides `-c` because `codex exec` has
164
+ // no dedicated flag for it (checked with `codex exec --help`, not from memory — LEARNINGS: never
165
+ // build against a documented flag from memory on a young foreign CLI).
122
166
  //
123
- // `vibe-acp` is the WRONG entry point for this use case and is deliberately not wired: it starts a
124
- // JSON-RPC server that speaks the Agent Client Protocol over stdio for IDE extensions (Zed et al.). It
125
- // expects a long-lived client session, not a one-shot prompt, so a script would have to implement an ACP
126
- // client to get a single review out of it. `vibe --prompt` is the scripting/CI path, and it is the one
127
- // Mistral documents for exactly this.
128
- export const VIBE_ARG_LIMIT = 256 * 1024;
129
- // Four turns was a truncation generator once denied reads consumed the budget; the origin project's live
130
- // probes settled on 24 as the bounded floor. VIBE_MAX_TURNS stays an explicit operator override.
131
- export const VIBE_MAX_TURNS = process.env.VIBE_MAX_TURNS || '24';
132
- // Optional: pin a model with `VIBE_MODEL`. Left unset by default so vibe uses the account's configured
133
- // default — unlike agy, an unset model here is not known to blank the output.
134
- export const VIBE_MODEL = process.env.VIBE_MODEL || null;
167
+ // `CODEX_MODEL=default` opts OUT of the pin and lets codex pick its BUILT-IN default model. Not the user's
168
+ // configured one: reviews run `--ignore-user-config` (see CODEX_REVIEW_FLAGS), so ~/.codex/config.toml —
169
+ // its model, and any custom model_provider/base URL — is not read. A setup that needs a custom provider
170
+ // cannot use the codex reviewer through this rail; route past it (`review-route.mjs --exclude codex`). It exists for a stranger whose codex
171
+ // account is not entitled to the pinned model: that fails loud rather than silently (see execCodex), and
172
+ // this is the one-word escape from it. It is an explicit choice, never the fallback for an unset var.
173
+ export const CODEX_MODEL = codexModelFrom(process.env.CODEX_MODEL);
174
+ export const CODEX_REASONING_EFFORT = process.env.CODEX_REASONING_EFFORT || 'high';
135
175
 
136
- // ── Claude Code (`claude`) as a plain CLI reviewer ───────────────────────────────────────────────────────
137
- // Sonnet by default: review is a single read of a bounded diff, and the routing policy reserves the
138
- // strongest model for the *fresh subagent* pass on high-risk work (see review-route.mjs). Override with
139
- // CLAUDE_REVIEW_MODEL.
176
+ /** `CODEX_MODEL`'s value: the pin when unset, `null` (codex's built-in default) for `default`. Pure, for tests. */
177
+ export function codexModelFrom(raw) {
178
+ const v = String(raw ?? '').trim();
179
+ if (!v) return 'gpt-5.6-terra';
180
+ return v.toLowerCase() === 'default' ? null : v;
181
+ }
182
+
183
+ // ── The BUILD tiers (2026-07-26) — Codex as a delegation target, not only a reviewer ────────────
184
+ // The product owner moved Codex onto a paid account and asked for it to carry delegated BUILD work, so the
185
+ // architect stays an architect. That makes "which model" a routing decision per task rather than
186
+ // one constant, and routing decisions belong in the repo for the same reason the review pin does:
187
+ // an ambient choice differs per machine and changes without a signal.
140
188
  //
141
- // The invocation is deliberately tool-less. `--tools ""` removes every built-in tool and
142
- // `--strict-mcp-config` (with no `--mcp-config`) loads no MCP servers, so the reviewer can do exactly one
143
- // thing: read the diff piped to it on stdin and write findings to stdout. It cannot edit the tree it is
144
- // reviewing, cannot shell out, and cannot wander into unrelated files — the same "single pass, advisory,
145
- // no writes" contract the other three CLIs get, enforced by flags rather than by hope.
146
- // `--bare` skips hook/skill/plugin/MCP/CLAUDE.md auto-discovery so a scripted call starts fast and, more
147
- // importantly, so the review is not silently reshaped by whatever config the host repo happens to carry.
148
- export const CLAUDE_REVIEW_MODEL = process.env.CLAUDE_REVIEW_MODEL || 'sonnet';
189
+ // ── The tier axis is EFFORT, not model — and the first draft of this table got that wrong ──────
190
+ // Probed live 2026-07-26 against codex-cli 0.144.6 on the product owner's paid ChatGPT account. `codex models
191
+ // list` does not exist in this version, so the roster had to be established by running it.
192
+ //
193
+ // **Exactly ONE model is entitled: `gpt-5.6-terra`.** Every other plausible slug —
194
+ // `gpt-5.6`, `gpt-5.6-mini`, `gpt-5.6-codex`, `gpt-5.6-terra-codex`, `gpt-5.6-terra-mini`,
195
+ // `codex-mini` — returns `400 … model is not supported when using Codex with a ChatGPT account`.
196
+ //
197
+ // This table's first draft listed four DIFFERENT models, because the probe that produced it scored
198
+ // success by grepping the output for "OK" instead of checking the exit code, and codex prints its
199
+ // session banner (and the model name) before failing. So every slug looked available. The error was
200
+ // caught by the delegation rail's own evidence section — a `quick` run reported the task complete-
201
+ // looking in its transcript and NOTHING written to the tree — which is precisely the check that rail
202
+ // exists for, working on its first real use.
203
+ //
204
+ // LEARNINGS already carries this rule from the Devin trial: *"a model catalog is not an entitlement
205
+ // list."* This is its second instance, with a sharper corollary: **score a CLI probe on its exit
206
+ // code, never on a pattern in its output**, because a young CLI prints a plausible banner on the way
207
+ // to failing. A bogus slug (`gpt-5.6-definitely-not-real-xyz`) was run as the discriminating case to
208
+ // confirm the failure is loud and distinguishable — it is.
209
+ //
210
+ // So routing is by `model_reasoning_effort`, which is a real axis: `minimal` is rejected as invalid,
211
+ // and low/medium/high/xhigh all run and are echoed back in codex's banner (verified per level, not
212
+ // assumed). Depth costs latency and tokens, so matching it to the work is still a decision worth
213
+ // making per task:
214
+ // deep — xhigh. Hardest reasoning: gnarly architecture, a subtle concurrency or auth question.
215
+ // build — high. Substantial multi-file stories. Same effort as the review pin above.
216
+ // standard — medium. The default for a bounded, well-specified LOW-risk story with clear
217
+ // acceptance — the shape WAYS-OF-WORKING calls delegable.
218
+ // quick — low. Mechanical work: doc edits, scaffolding, a rename across files.
219
+ //
220
+ // HIGH-risk stories (credentials, migrations, a first mutation surface) are NOT on this table. They
221
+ // are architect-only by the sprint doc, and a tier name would invite routing one here by accident.
222
+ export const CODEX_BUILD_TIERS = {
223
+ deep: { model: 'gpt-5.6-terra', effort: 'xhigh' },
224
+ build: { model: 'gpt-5.6-terra', effort: 'high' },
225
+ standard: { model: 'gpt-5.6-terra', effort: 'medium' },
226
+ quick: { model: 'gpt-5.6-terra', effort: 'low' },
227
+ };
228
+
229
+ /** Resolve a tier name to {model, effort}, or die listing the valid ones. */
230
+ export function resolveCodexTier(tier) {
231
+ const hit = CODEX_BUILD_TIERS[tier];
232
+ if (!hit) {
233
+ die(
234
+ `unknown codex tier "${tier}" — valid tiers: ${Object.keys(CODEX_BUILD_TIERS).join(', ')}. ` +
235
+ `Tiers are declared in scripts/lib/cross-agent-cli.mjs (CODEX_BUILD_TIERS).`
236
+ );
237
+ }
238
+ return hit;
239
+ }
240
+
241
+ // ── WHO MAY REVIEW WHAT: builder family ≠ reviewer family ───────────────────────────────────────
242
+ // The product owner's rule (2026-07-26), and it is a sharpening of what LEARNINGS already argues rather than a
243
+ // new policy: *"if a builder was from codex then reviewers could be from claude or agy; if the
244
+ // builder was claude then reviewers would be codex and/or agy."*
245
+ //
246
+ // The evidence behind it is this repo's own. pod-report S3 ran FOUR agy rounds on a new credential
247
+ // surface and the fourth, aimed deliberately at the auth/tenancy surface, came back CLEAN. Codex
248
+ // then opened with a Blocking finding on that same surface. Neither family is better; they are
249
+ // blind in different directions, which is the entire reason to run both — and it is why a family
250
+ // clearing its own work is the one arrangement that buys nothing.
251
+ //
252
+ // Now that Codex BUILDS here (scripts/codex-task.mjs) and not only reviews, that failure mode is
253
+ // newly reachable: the obvious `--agent codex` on a Codex-built diff is same-family self-review
254
+ // wearing a cross-review label, which is worse than no review because it is recorded as one.
255
+ //
256
+ // Encoded as a REFUSAL rather than a convention, because a convention drifts and this one would
257
+ // drift silently — the output looks identical either way. `reviewersFor` is pure and tested.
258
+ //
259
+ // Cost note (the product owner's standing preference): Claude's tokens go to security/money/architecture and to
260
+ // BUILDING, not to routine PR review. So Claude is deliberately NOT in either default reviewer set;
261
+ // it is the escalation, named explicitly when a diff earns it, not the baseline.
262
+ // `vibe` added 2026-08-25 (the product owner: "Agy, codex and vibe should be enabled for cross family
263
+ // reviews"). It was already a first-class REVIEWER in review-route.mjs's preference order and in
264
+ // AGENT_FLAG, but it was missing here — so `--builder vibe` was refused as an unknown family, and a
265
+ // vibe-built diff could only be reviewed by mislabelling who wrote it. That is the same-family guard
266
+ // failing open through a roster gap rather than a logic bug, which is the harder kind to notice.
267
+ export const BUILDER_FAMILIES = ['claude', 'codex', 'agy', 'vibe', 'human'];
268
+
269
+ /**
270
+ * Which reviewer agents may review a diff built by `builder`.
271
+ *
272
+ * `human` and an unknown/unstated builder both get the full set: a human-written diff has no model
273
+ * family to collide with, and refusing to review an unlabelled diff would make the safe path the
274
+ * annoying one, which is how a check gets bypassed.
275
+ */
276
+ export function reviewersFor(builder) {
277
+ const b = String(builder || '').toLowerCase();
278
+ // Every set below is "the roster minus the builder's own family". Written out per builder rather
279
+ // than derived, so the policy reads as a decision and a wrong pairing is visible on one line.
280
+ if (b === 'codex') return ['antigravity', 'vibe'];
281
+ if (b === 'agy') return ['codex', 'vibe'];
282
+ if (b === 'vibe') return ['codex', 'antigravity'];
283
+ if (b === 'claude') return ['codex', 'antigravity', 'vibe'];
284
+ return ['codex', 'antigravity', 'vibe'];
285
+ }
286
+
287
+ /** Normalise the reviewer CLI name to the family it belongs to. */
288
+ function reviewerFamily(agent) {
289
+ return agent === 'antigravity' ? 'agy' : agent;
290
+ }
291
+
292
+ /**
293
+ * Refuse a same-family review. Returns null when the pairing is fine, or an explanatory message.
294
+ *
295
+ * Deliberately returns the reason instead of dying here, so the caller can decide between a hard
296
+ * failure (an interactive run) and a warning (a batch), and so this stays pure and testable.
297
+ */
298
+ export function checkReviewerPairing(builder, agent) {
299
+ const b = String(builder || '').toLowerCase();
300
+ if (!b || b === 'human') return null;
301
+ // An UNRECOGNISED explicit builder is a refusal, not a shrug (cross-review, Codex, PR #38).
302
+ // Unknown values used to fall through to the same "no constraint" branch as an unstated builder,
303
+ // so `--builder codez --agent codex` — a typo — silently disabled the same-family guard and
304
+ // produced a review that looked exactly like a valid one. A guard that a typo can switch off is
305
+ // not a guard; an omitted builder is still permitted, because that is an honest "unstated".
306
+ if (!BUILDER_FAMILIES.includes(b)) {
307
+ return (
308
+ `unknown --builder "${builder}" — expected one of ${BUILDER_FAMILIES.join(', ')}. ` +
309
+ `Refusing rather than defaulting: an unrecognised builder would silently disable the ` +
310
+ `same-family review guard, and the output looks identical either way.`
311
+ );
312
+ }
313
+ if (reviewerFamily(agent) !== b) return null;
314
+ const allowed = reviewersFor(b).join(', ');
315
+ return (
316
+ `refusing a SAME-FAMILY review: this diff was built by "${b}" and "${agent}" is the same model ` +
317
+ `family. A family clearing its own work is the one arrangement that buys nothing — the two ` +
318
+ `families are blind in different directions, which is the whole point of the gate ` +
319
+ `(Roadmap/LEARNINGS.md). Use --agent ${allowed} instead, or pass --builder human if a person ` +
320
+ `wrote this diff.`
321
+ );
322
+ }
323
+
324
+ // agy takes the prompt+context as a single `-p` argv string (stdin is not the prompt). Guard well under the
325
+ // OS limit (macOS ARG_MAX is 1 MB incl. env) so a huge input fails clearly instead of an opaque E2BIG.
326
+ export const AGY_ARG_LIMIT = 256 * 1024;
149
327
 
150
328
  export function die(msg) {
151
329
  process.stderr.write(`✗ ${msg}\n`);
@@ -223,12 +401,94 @@ export function isDocFile(path) {
223
401
  return DOC_FILE_RE.test(path || '') || /(^|\/)docs\//i.test(path || '');
224
402
  }
225
403
 
404
+ /**
405
+ * Drop documentation/text file hunks from a diff, leaving only code.
406
+ *
407
+ * ── Why this exists, and why the SCOPE has to be reported ─────────────────────────────────────
408
+ * agy takes its whole prompt as one argv string (stdin is not the prompt), so it is bounded by
409
+ * AGY_ARG_LIMIT — and a sprint-sized PR crosses it. signals-loop Sprint 3 hit 259 KB against a
410
+ * 256 KB cap and the review simply refused to run, which on a HIGH-risk credential surface is the
411
+ * worst possible time to lose the second family's read.
412
+ *
413
+ * This repo's PRs are comment-dense by house style and carry sprint docs, so prose is usually the
414
+ * majority of the bytes and dropping it is enough. The reviewer then sees every line of code and
415
+ * none of the documentation.
416
+ *
417
+ * **That is a REAL reduction in scope, not a formatting detail.** A reviewer who cannot see the
418
+ * sprint doc cannot check the code against its stated acceptance criteria, and one who cannot see a
419
+ * migration's comment cannot catch a comment asserting a property the SQL does not enforce — a
420
+ * defect class this repo has hit repeatedly. So callers must state the subset in the posted comment
421
+ * (cross-review.mjs does), because a review labelled as covering a PR while having seen two thirds
422
+ * of it is worse than an absent review: the next reader stops there.
423
+ */
424
+ export function stripDocFileDiffs(diffText) {
425
+ if (!diffText) return { diff: diffText, strippedFiles: [] };
426
+ const chunks = diffText.split(/(?=^diff --git )/m);
427
+ const strippedFiles = [];
428
+ const kept = [];
429
+ for (const chunk of chunks) {
430
+ const header = chunk.match(/^diff --git a\/(\S+) b\/(\S+)/);
431
+ if (!header) {
432
+ kept.push(chunk);
433
+ continue;
434
+ }
435
+ const path = header[2] || header[1];
436
+ if (isDocFile(path)) {
437
+ strippedFiles.push(path);
438
+ continue;
439
+ }
440
+ kept.push(chunk);
441
+ }
442
+ return { diff: kept.join(''), strippedFiles };
443
+ }
444
+
445
+ /**
446
+ * Keep only the diff hunks whose path matches one of `patterns` (substring match).
447
+ *
448
+ * ── Why a targeted subset is sometimes the ONLY way to get the second family ──────────────────
449
+ * agy takes its prompt in argv, so it is bounded by AGY_ARG_LIMIT. A long-running PR grows past
450
+ * that even after `stripDocFileDiffs` — signals-loop Sprint 3 reached 270 KB of pure code across
451
+ * seven review rounds — and at that point the choice is a scoped review or none at all.
452
+ *
453
+ * Scoping is the better answer for a HIGH-risk PR specifically, because the risk is concentrated:
454
+ * a credential surface and a mutation path are worth a full read from both families, while a
455
+ * landing-page component is not what the second opinion is for.
456
+ *
457
+ * It is still a REDUCTION, and callers must say so in the posted comment — a review that appears to
458
+ * cover a PR while having seen four files is worse than an absent one.
459
+ */
460
+ export function filterDiffToPaths(diffText, patterns) {
461
+ if (!diffText || !patterns?.length) return { diff: diffText, keptFiles: [], droppedFiles: [] };
462
+ const chunks = diffText.split(/(?=^diff --git )/m);
463
+ const keptFiles = [];
464
+ const droppedFiles = [];
465
+ const kept = [];
466
+ for (const chunk of chunks) {
467
+ const header = chunk.match(/^diff --git a\/(\S+) b\/(\S+)/);
468
+ if (!header) {
469
+ kept.push(chunk);
470
+ continue;
471
+ }
472
+ const path = header[2] || header[1];
473
+ if (patterns.some((pat) => path.includes(pat))) {
474
+ keptFiles.push(path);
475
+ kept.push(chunk);
476
+ } else {
477
+ droppedFiles.push(path);
478
+ }
479
+ }
480
+ return { diff: kept.join(''), keptFiles, droppedFiles };
481
+ }
482
+
226
483
  export function decideTrivialSkip({ files, minLines = 10 } = {}) {
227
484
  if (!Array.isArray(files) || files.length === 0) return { skip: true, reason: 'empty diff' };
228
485
  if (files.every((f) => isDocFile(f.path))) return { skip: true, reason: 'docs-only diff' };
229
486
  const lines = files.reduce((n, f) => n + (f.additions || 0) + (f.deletions || 0), 0);
230
487
  if (lines < minLines)
231
- return { skip: true, reason: `trivial diff (${lines} changed line${lines === 1 ? '' : 's'} < ${minLines})` };
488
+ return {
489
+ skip: true,
490
+ reason: `trivial diff (${lines} changed line${lines === 1 ? '' : 's'} < ${minLines})`,
491
+ };
232
492
  return { skip: false };
233
493
  }
234
494
 
@@ -249,7 +509,8 @@ export function decideTrivialSkip({ files, minLines = 10 } = {}) {
249
509
  // unit-testable against a hand-built diff fixture.
250
510
  // `jev-eval.fixtures.json` (jev-semantic-guards): recorded Jev answers that `jev-eval --live` rewrites — ~100 KB of
251
511
  // machine-written JSON that alone pushed a 20-file PR past agy's/vibe's 256 KB argv limit.
252
- const GENERATED_FILE_RE = /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|Gemfile\.lock|Cargo\.lock|poetry\.lock|reports-data\.json|jev-eval\.fixtures\.json)$/;
512
+ const GENERATED_FILE_RE =
513
+ /(^|\/)(package-lock\.json|npm-shrinkwrap\.json|yarn\.lock|pnpm-lock\.yaml|composer\.lock|Gemfile\.lock|Cargo\.lock|poetry\.lock|reports-data\.json|jev-eval\.fixtures\.json)$/;
253
514
 
254
515
  export function stripGeneratedFileDiffs(diffText, { extraPatterns = [] } = {}) {
255
516
  if (!diffText) return { diff: diffText, strippedFiles: [] };
@@ -276,6 +537,20 @@ export function stripGeneratedFileDiffs(diffText, { extraPatterns = [] } = {}) {
276
537
  // diagnostics ("tokens used\n0") can land on either depending on the failure path, which is what made this
277
538
  // failure mode read as an opaque `(non-auth): 0` before this check existed. Kept as a named, tested export so
278
539
  // the message stays actionable instead of falling through to the generic non-auth failure text.
540
+ // True when an agy failure is TRANSIENT — provider capacity, rate limiting or a passing upstream
541
+ // blip — as opposed to a real interface error (bad flag, crash, unknown subcommand). Only a
542
+ // transient failure justifies spending the fallback model, which draws on a separate capacity pool.
543
+ //
544
+ // Kept deliberately TIGHT. A loose pattern here is actively harmful: it would convert genuine
545
+ // breakage (the 1.0.10 class of contract change this whole file exists to catch) into a silent
546
+ // retry on a second model, which is exactly how empty reviews shipped for weeks. Every phrase below
547
+ // is one observed live, and anything unrecognized still fails loud.
548
+ export function isTransientAgyError(stderr) {
549
+ return /high traffic|temporarily unavailable|try again (in a|later)|rate ?limit|too many requests|RESOURCE_EXHAUSTED|\b(429|500|502|503|504)\b|overloaded|capacity|timed? ?out|ECONNRESET|ETIMEDOUT|EAI_AGAIN/i.test(
550
+ stderr || ''
551
+ );
552
+ }
553
+
279
554
  export function isContextWindowOverflow(output) {
280
555
  // ['’] covers both a straight and a "smart"/curly apostrophe — CLIs are inconsistent about which they emit.
281
556
  return /ran out of room in the model['’]?s context window/i.test(output || '');
@@ -345,7 +620,7 @@ export function checkAgyVersion(deps = {}) {
345
620
  if (m[0] !== pinned)
346
621
  return failFn(
347
622
  `agy ${m[0]} != pinned ${pinned} — the print/--model contract may have shifted. ` +
348
- `Run \`node scripts/agy-doctor.mjs --fix\` (authorized for agents: it re-verifies the live ` +
623
+ `Run \`node scripts/cross-agent-doctor.mjs agy --fix\` (authorized for agents: it re-verifies the live ` +
349
624
  `contract and bumps the pin only on a green probe), then commit the bump. ` +
350
625
  `Manual path: re-verify runAntigravity() against \`agy --help\`, then bump AGY_PINNED to ${m[0]}.`
351
626
  );
@@ -380,13 +655,53 @@ function lastLine(stderr) {
380
655
  // Low-level codex exec: prompt rides as an argv string, context is piped on stdin (codex appends it as a
381
656
  // <stdin> block). Returns the raw spawn result — callers decide how to interpret status/stdout/stderr.
382
657
  function execCodex(prompt, stdin) {
383
- return spawnSync('codex', ['exec', prompt], {
658
+ // --model and the reasoning-effort override are passed EXPLICITLY rather than inherited from
659
+ // ~/.codex/config.toml. See CODEX_MODEL's header comment: an ambient model choice is the same
660
+ // silent-rot surface that made every prose draft run on the wrong model for a release cycle, with
661
+ // the added problem that an ambient value differs per machine.
662
+ //
663
+ // Unlike agy, an unrecognized codex --model FAILS LOUD (non-zero exit with the model name in
664
+ // stderr) rather than substituting a default — verified 2026-07-26 — so a rotted pin here surfaces
665
+ // as a broken review rather than a quietly downgraded one. That is why this needs no doctor probe
666
+ // of its own, where the agy pins do.
667
+ return spawnSync('codex', codexExecArgs(prompt), {
384
668
  input: stdin,
385
669
  encoding: 'utf8',
386
670
  maxBuffer: 64 * 1024 * 1024,
387
671
  });
388
672
  }
389
673
 
674
+ /**
675
+ * The `codex exec` argv. Pure, for tests: `model: null` means codex's built-in default (CODEX_MODEL=default).
676
+ *
677
+ * ── The reviewer is locked down, not trusted (distribute-what-we-use S1, pr-reviewer round 2 on #188) ──
678
+ * Codex reads an attacker-controllable diff. Without these flags it ran with whatever ~/.codex/config.toml
679
+ * said — observed: `sandbox: workspace-write`, `approval: on-request`, and the user's MCP servers started
680
+ * (a database one among them). Pinned instead, and observed live with codex-cli 0.158.0:
681
+ * --sandbox read-only no writes and no network from commands the model runs;
682
+ * --ignore-user-config no user MCP servers, no ambient profile (auth still comes from CODEX_HOME);
683
+ * --ephemeral no session file written for a review.
684
+ * The banner then reads `sandbox: read-only`, `approval: never`, with no MCP worker. What remains is READ
685
+ * access to the host — no codex flag removes it — which is why cross-review refuses to post a reply that
686
+ * carries a secret (lib/secret-guard.mjs). Reduced, not closed; said so in the CHANGELOG.
687
+ * Also used by lib/prose-writer.mjs's codex path, where read-only is equally right.
688
+ */
689
+ export const CODEX_REVIEW_FLAGS = Object.freeze([
690
+ '--sandbox',
691
+ 'read-only',
692
+ '--ignore-user-config',
693
+ // A hostile PR's checkout is on disk while it is reviewed; its execpolicy `.rules` must not load either.
694
+ '--ignore-rules',
695
+ '--ephemeral',
696
+ ]);
697
+
698
+ export function codexExecArgs(prompt, { model = CODEX_MODEL, effort = CODEX_REASONING_EFFORT } = {}) {
699
+ // Effort is independent of the model: `CODEX_MODEL=default` changes WHICH model, not how hard it thinks
700
+ // (codex round 4 on #188).
701
+ const pin = [...(model ? ['--model', model] : []), '-c', `model_reasoning_effort=${effort}`];
702
+ return ['exec', ...CODEX_REVIEW_FLAGS, ...pin, prompt];
703
+ }
704
+
390
705
  // codex exec wrapper preserving the original contract: returns trimmed stdout, or fail()s (die unless soft).
391
706
  export function runCodex(prompt, stdin, opts = {}) {
392
707
  const r = execCodex(prompt, stdin);
@@ -408,10 +723,40 @@ export function tryCodex(prompt, stdin) {
408
723
  // See stripGeneratedFileDiffs' header comment: checked on both streams because codex's own trailing
409
724
  // diagnostics can land on either, depending on the exact failure path.
410
725
  contextOverflow: r.status !== 0 && (isContextWindowOverflow(stdout) || isContextWindowOverflow(stderr)),
726
+ // Stale CLI: the installed codex is too old for the model it is asked to run. Like an auth lapse,
727
+ // codex cannot run here and agy can, so it triggers the same one-shot fallback. A distinct flag (not
728
+ // folded into authFailed) so the banner names the real cause and points at the doctor.
729
+ cliOutdated: r.status !== 0 && isCodexOutdated(`${stdout}\n${stderr}`),
730
+ capped: r.status !== 0 && isCodexCapped(`${stdout}\n${stderr}`),
411
731
  stderr,
412
732
  };
413
733
  }
414
734
 
735
+ // True when codex fails because the INSTALLED CLI is too old for the model it runs — a distinct class from
736
+ // an auth lapse or a context overflow. Confirmed live by a consuming project (2026-07-20, codex-cli 0.142.5):
737
+ // "The 'gpt-5.6-sol' model requires a newer version of Codex. Please upgrade to the latest app or CLI…"
738
+ // Kept tight to the upgrade signal so a bad-model-name typo (a different 400) is not masked as "upgrade".
739
+ // True when the account cannot use the requested model (an entitlement refusal, not a stale binary).
740
+ export function isCodexModelUnavailable(output) {
741
+ return /model (?:is )?not (?:supported|available|found)|not supported when using codex|does not have access to (?:the )?model|unknown model/i.test(
742
+ output || ''
743
+ );
744
+ }
745
+
746
+ // True when codex refused because the account hit its usage cap (observed 2026-09-29, codex-cli 0.158.0:
747
+ // "You've hit your usage limit"). Codex cannot run here and agy draws on a different pool, so it heals like
748
+ // an auth lapse or a stale CLI.
749
+ export function isCodexCapped(output) {
750
+ return /hit your usage limit|usage limit reached|rate limit exceeded for your plan/i.test(output || '');
751
+ }
752
+
753
+ export function isCodexOutdated(output) {
754
+ // Also: a codex too old to know the review's lockdown flags (clap: "unexpected argument '--ignore-rules'").
755
+ return /unexpected argument '--(?:ignore-user-config|ignore-rules|ephemeral|sandbox)'|requires a newer version of codex|upgrade to the latest (?:app or )?cli|update codex|codex (?:is )?out of date/i.test(
756
+ output || ''
757
+ );
758
+ }
759
+
415
760
  // True when codex's stderr carries an AUTHENTICATION failure (lapsed/revoked/expired token) — the only
416
761
  // signal that should trigger the Antigravity fallback. Confirmed against a live revoked token (2026-06-21):
417
762
  // "Failed to refresh token: 401 Unauthorized … refresh_token_invalidated", "Your session has ended. Please
@@ -425,23 +770,33 @@ export function isCodexAuthError(stderr) {
425
770
 
426
771
  // Pure fallback decision — the unit under test. Given the outcome of a codex attempt and whether agy is
427
772
  // available, return the action to take. No I/O, no exit.
428
- export function decideCodexFallback({ codexOk, authFailed, contextOverflow, agyAvailable }) {
773
+ export function decideCodexFallback({
774
+ codexOk,
775
+ authFailed,
776
+ cliOutdated = false,
777
+ capped = false,
778
+ contextOverflow,
779
+ agyAvailable,
780
+ fallbackPairingError = null,
781
+ }) {
429
782
  if (codexOk) return 'use-codex';
430
- // Checked BEFORE authFailed: an overflow is a distinct failure class from auth (retrying with a fallback
431
- // model wouldn't help — the input itself is too big, not the credential), so it gets its own clear
432
- // message rather than falling through to the generic "(non-auth): <cryptic tail line>" text.
783
+ // Checked BEFORE the recoverable classes: an overflow is distinct from auth/stale-CLI (retrying with a
784
+ // fallback model wouldn't help — the input itself is too big), so it gets its own clear message rather
785
+ // than falling through to the generic "(non-auth): <cryptic tail line>" text.
433
786
  if (contextOverflow) return 'fail-context-overflow';
434
- if (!authFailed) return 'fail-non-auth'; // codex broke for a non-auth reason — don't mask it behind a fallback
787
+ // Both an auth lapse AND a too-old CLI mean "codex can't run here, agy can" → the same one-shot fallback.
788
+ if (!authFailed && !cliOutdated && !capped) return 'fail-non-auth';
435
789
  if (!agyAvailable) return 'fail-both-dead';
790
+ // The fallback CHANGES the reviewing family. When agy built the diff, falling back to agy would be a
791
+ // same-family review wearing a cross-family label, so it fails loud instead (distribute-what-we-use D1).
792
+ if (fallbackPairingError) return 'fail-fallback-same-family';
436
793
  return 'fallback';
437
794
  }
438
795
 
439
- // Orchestrate codex with a one-shot Antigravity fallback on an auth failure. `deps` is injectable so a
440
- // pure node:test can mock both runners (no network). Returns { findings, fellBack[, from, to] }.
441
- export function runWithCodexFallback(
442
- { prompt, stdin, antigravityArgv },
443
- deps = {}
444
- ) {
796
+ // Orchestrate codex with a one-shot Antigravity fallback on an auth lapse or a stale CLI. `builder` (who
797
+ // wrote the diff, optional) is re-checked against the FALLBACK family. `deps` is injectable so a pure
798
+ // node:test can mock both runners (no network). Returns { findings, fellBack[, from, to] }.
799
+ export function runWithCodexFallback({ prompt, stdin, antigravityArgv, builder = '' }, deps = {}) {
445
800
  const {
446
801
  tryCodex: tryCodexFn = tryCodex,
447
802
  runAntigravity: runAntigravityFn = runAntigravity,
@@ -451,13 +806,31 @@ export function runWithCodexFallback(
451
806
  } = deps;
452
807
 
453
808
  const codex = tryCodexFn(prompt, stdin);
809
+ const fallbackPairingError = checkReviewerPairing(builder, 'antigravity');
454
810
  const action = decideCodexFallback({
455
811
  codexOk: codex.ok,
456
812
  authFailed: codex.authFailed,
813
+ cliOutdated: codex.cliOutdated,
814
+ capped: codex.capped,
457
815
  contextOverflow: codex.contextOverflow,
458
816
  agyAvailable: hasCmdFn('agy'),
817
+ fallbackPairingError,
459
818
  });
460
819
 
820
+ // The two recoverable causes want different operator guidance — a lapsed token vs a stale binary — so the
821
+ // banner/fatal text branches on the flag even though the ACTION (fall back to agy) is the same.
822
+ const cause = codex.capped
823
+ ? {
824
+ blurb: 'Codex hit its usage cap',
825
+ restore: 'wait for the cap to reset, or route past it with --exclude codex',
826
+ }
827
+ : codex.cliOutdated
828
+ ? {
829
+ blurb: 'Codex CLI is behind its model requirement',
830
+ restore: 'upgrade codex (see `node scripts/cross-agent-doctor.mjs codex`)',
831
+ }
832
+ : { blurb: 'Codex token revoked', restore: '`codex login`' };
833
+
461
834
  switch (action) {
462
835
  case 'use-codex':
463
836
  return { findings: codex.text, fellBack: false };
@@ -465,20 +838,34 @@ export function runWithCodexFallback(
465
838
  return failFn(
466
839
  "codex exec failed: the diff is too large for Codex's context window. " +
467
840
  'This is usually a large auto-generated file (a lockfile, a minified bundle, a snapshot) — ' +
468
- 'stripGeneratedFileDiffs() already excludes the known lockfile patterns by default, so if you\'re ' +
841
+ "stripGeneratedFileDiffs() already excludes the known lockfile patterns by default, so if you're " +
469
842
  'seeing this, either that allowlist needs a new pattern for this file, or the PR has a genuinely ' +
470
843
  'large hand-written diff that needs splitting.'
471
844
  );
472
845
  case 'fail-non-auth':
473
- return failFn(`codex exec failed (non-auth): ${lastLine(codex.stderr)}`);
846
+ // A pinned model this codex account cannot use fails here, not as auth — so name the escape. Matched on
847
+ // the entitlement error itself: codex's banner prints `model: …` on EVERY run, so the bare word would
848
+ // advise dropping the pin on any failure (pr-reviewer round 2, S2).
849
+ return failFn(
850
+ `codex exec failed (non-auth): ${lastLine(codex.stderr)}` +
851
+ (isCodexModelUnavailable(codex.stderr) && CODEX_MODEL
852
+ ? ` — if this account cannot use "${CODEX_MODEL}", re-run with CODEX_MODEL=default (codex's built-in default).`
853
+ : '')
854
+ );
474
855
  case 'fail-both-dead':
475
856
  return failFn(
476
- 'Codex token revoked AND Antigravity unavailable — restore Codex with `codex login`, ' +
857
+ `${cause.blurb} AND Antigravity unavailable — restore Codex (${cause.restore}), ` +
477
858
  'or install + authenticate the Antigravity CLI (agy).'
478
859
  );
860
+ case 'fail-fallback-same-family':
861
+ return failFn(
862
+ `${cause.blurb}, and the only fallback is Antigravity — ${fallbackPairingError} ` +
863
+ `Restore Codex (${cause.restore}), or re-route past codex with ` +
864
+ `\`node scripts/review-route.mjs --builder ${builder} --exclude codex <PR#>\`.`
865
+ );
479
866
  case 'fallback':
480
867
  default:
481
- warn('⚠ Codex unavailable (token revoked) → falling back to Antigravity. Restore: `codex login`.');
868
+ warn(`⚠ ${cause.blurb} → falling back to Antigravity. Restore: ${cause.restore}.`);
482
869
  return {
483
870
  findings: runAntigravityFn(antigravityArgv),
484
871
  fellBack: true,
@@ -499,6 +886,214 @@ function execAgy(fullArgv, model, spawn) {
499
886
  });
500
887
  }
501
888
 
889
+ // ── Whole-file context for the diff-only reviewers ───────────────────────────────────────────────────────
890
+ // Both agy and vibe are handed a DIFF. A diff shows changed lines and a few of context, which is why
891
+ // this rail has produced the same wrong finding three times in two days: "the helper is not defined
892
+ // in this test file" (defined eight lines above the hunk), "an audit action carries no project_id"
893
+ // (the source passes one), "imported from a file the diff never creates" (a lower PR creates it).
894
+ // Every one is a reviewer reasoning about code it could not see.
895
+ //
896
+ // vibe briefly got repo ACCESS instead (read_file + grep, 2026-08-07); that was removed because it let an
897
+ // injected diff read host files (see VIBE_READ_ONLY_TOOLS), so vibe is attached-to like agy. **agy could
898
+ // never have had it**: its only permission lever is `--dangerously-skip-permissions`, which is
899
+ // all-or-nothing — there is no --enabled-tools equivalent to scope it to reads — and without it agy
900
+ // simply BLOCKS in `-p` mode waiting for an approval that never comes (measured: no output after
901
+ // 9 minutes, with and without `--mode plan`). Granting an external CLI blanket tool approval in this
902
+ // repo would also be strictly worse than what vibe got, because a permission skip covers shell and
903
+ // network too, not just file writes.
904
+ //
905
+ // So the missing context is ATTACHED instead of fetched. It is deterministic, needs no permission
906
+ // grant, cannot hang, and puts exactly the thing the reviewer kept guessing about in front of it.
907
+ //
908
+ // ── The byte budget is not optional, and neither is the manifest ────────────────────────────────
909
+ // agy's argv cap is 256 KB. Measured on real PRs from this repo: a small PR is ~35 KB with full text
910
+ // attached, a sprint-sized one is ~302 KB — over the cap. So attachment is BOUNDED, smallest files
911
+ // first (more files for the same bytes), and every file that did not fit is NAMED.
912
+ //
913
+ // Naming them matters more than including them. A reviewer given some files and not others, with no
914
+ // list, would conclude an unattached file does not exist — which is the exact failure this function
915
+ // exists to fix, made worse. The manifest says what was attached, what was not, and why.
916
+ /**
917
+ * The HEAD-side paths of every file a unified diff touches, excluding deletions.
918
+ *
919
+ * ── Why the `b/` side, and why deletions are dropped ─────────────────────────────────────────
920
+ * Callers attach whole-file context read from the PR's HEAD commit. The `a/` (pre-image) path is
921
+ * the wrong side for that: on a RENAME it is the OLD name, which does not exist at head — so the
922
+ * lookup misses, the file is silently dropped from the reviewer's context, and the code looks like
923
+ * it attached it. A DELETED file has no head-side content at all, so asking for it can only fail.
924
+ *
925
+ * Both were live: `cross-review.mjs` used `/^diff --git a\/(\S+) b\/\S+$/gm` and read the `a/`
926
+ * capture (cross-review, Codex, PR #119).
927
+ *
928
+ * Split per file rather than scanning the whole diff, because `+++ /dev/null` marks a deletion only
929
+ * for the file header it belongs to — matched globally it would suppress unrelated files.
930
+ */
931
+ export function headSidePaths(diff) {
932
+ return String(diff || '')
933
+ .split(/^diff --git /m)
934
+ .slice(1)
935
+ .map((chunk) => {
936
+ // The `+++` file marker lives in the HEADER, before the first `@@` hunk. Scanning the whole
937
+ // chunk was wrong: inside a hunk, an ADDED line is rendered with a leading `+`, so a source
938
+ // line whose literal text is `++ /dev/null` produces the byte-identical `+++ /dev/null` and
939
+ // would silently drop a file that was never deleted (cross-review, Codex, PR #119, round 3).
940
+ // This file's own fixtures contain such strings, so the hazard is not theoretical here.
941
+ const hunkStart = chunk.search(/^@@ /m);
942
+ const header = hunkStart === -1 ? chunk : chunk.slice(0, hunkStart);
943
+ // TWO deletion signals, because neither covers every case on its own:
944
+ // `deleted file mode` — git emits it for every deletion, text or BINARY.
945
+ // `+++ /dev/null` — the text-diff marker.
946
+ // A BINARY deletion has no `+++` line at all (it renders as
947
+ // `Binary files a/x and /dev/null differ`), so checking only the marker attached a path that
948
+ // cannot exist at head and then reported it as "unavailable" — a misleading warning about a
949
+ // file that is correctly absent (cross-review, Codex, PR #119, round 4).
950
+ if (/^deleted file mode /m.test(header) || /^\+\+\+ \/dev\/null$/m.test(header)) return null;
951
+ return headPathFromDiffHeader(chunk.split('\n', 1)[0]);
952
+ })
953
+ .filter((path) => path !== null);
954
+ }
955
+
956
+ /**
957
+ * The `b/` path out of one `diff --git` header line, handling Git's quoting.
958
+ *
959
+ * Git quotes a pathname whenever it contains a space, a control character or a non-ASCII byte, and
960
+ * then C-escapes it: `diff --git "a/my file.ts" "b/my file.ts"`. The first version of this function
961
+ * treated a quoted header as unparseable and dropped the file — silently omitting it from the
962
+ * reviewer's context, which is precisely the defect this whole seam exists to remove (cross-review,
963
+ * Codex, PR #119, round 2). Losing a renamed file and losing an accented one are the same bug.
964
+ */
965
+ function headPathFromDiffHeader(header) {
966
+ const sides = splitDiffHeaderSides(header);
967
+ if (sides === null) return null;
968
+ const head = unquoteGitPath(sides.head);
969
+ return head !== null && head.startsWith('b/') ? head.slice(2) : null;
970
+ }
971
+
972
+ /** The two raw (still-quoted) sides of a `diff --git` header, or null if it does not parse. */
973
+ function splitDiffHeaderSides(header) {
974
+ const text = String(header || '');
975
+ // Quoted sides are unambiguous — read the closing quote, honouring backslash escapes.
976
+ if (text.startsWith('"')) {
977
+ const end = findClosingQuote(text, 0);
978
+ if (end === -1) return null;
979
+ const rest = text.slice(end + 1).trimStart();
980
+ return rest === '' ? null : { pre: text.slice(0, end + 1), head: rest };
981
+ }
982
+ // Unquoted `a/…`: the head side starts at the LAST ` b/` or ` "b/`, because an unquoted path
983
+ // cannot contain a space and therefore cannot itself contain " b/".
984
+ const marker = text.lastIndexOf(' b/') >= 0 ? text.lastIndexOf(' b/') : text.lastIndexOf(' "b/');
985
+ if (marker === -1) return null;
986
+ return { pre: text.slice(0, marker), head: text.slice(marker + 1) };
987
+ }
988
+
989
+ function findClosingQuote(text, openIndex) {
990
+ for (let i = openIndex + 1; i < text.length; i++) {
991
+ if (text[i] === '\\') {
992
+ i++;
993
+ continue;
994
+ }
995
+ if (text[i] === '"') return i;
996
+ }
997
+ return -1;
998
+ }
999
+
1000
+ /**
1001
+ * Reverse Git's C-style path quoting.
1002
+ *
1003
+ * Octal escapes are the reason this collects BYTES before decoding: git emits a non-ASCII character
1004
+ * as one escape per UTF-8 byte (`é` → `\303\251`), so decoding each escape as its own character
1005
+ * would produce mojibake rather than the filename. An unquoted path is returned unchanged.
1006
+ */
1007
+ function unquoteGitPath(raw) {
1008
+ const text = String(raw || '');
1009
+ if (!text.startsWith('"')) return text;
1010
+ const end = findClosingQuote(text, 0);
1011
+ if (end === -1) return null;
1012
+ const body = text.slice(1, end);
1013
+ const bytes = [];
1014
+ const SIMPLE = { a: 7, b: 8, t: 9, n: 10, v: 11, f: 12, r: 13, '"': 34, '\\': 92 };
1015
+ for (let i = 0; i < body.length; i++) {
1016
+ if (body[i] !== '\\') {
1017
+ for (const byte of Buffer.from(body[i], 'utf8')) bytes.push(byte);
1018
+ continue;
1019
+ }
1020
+ const next = body[++i];
1021
+ if (next === undefined) return null;
1022
+ if (next >= '0' && next <= '7') {
1023
+ const octal = body.slice(i, i + 3);
1024
+ if (!/^[0-7]{3}$/.test(octal)) return null;
1025
+ bytes.push(parseInt(octal, 8));
1026
+ i += 2;
1027
+ continue;
1028
+ }
1029
+ if (!(next in SIMPLE)) return null;
1030
+ bytes.push(SIMPLE[next]);
1031
+ }
1032
+ return Buffer.from(bytes).toString('utf8');
1033
+ }
1034
+
1035
+ export function buildFileContext(paths, readFile, budgetBytes, opts = {}) {
1036
+ const { maxFiles = 40 } = opts;
1037
+ const seen = new Set();
1038
+ const candidates = [];
1039
+ for (const path of paths) {
1040
+ if (seen.has(path)) continue;
1041
+ seen.add(path);
1042
+ let text = null;
1043
+ try {
1044
+ text = readFile(path);
1045
+ } catch {
1046
+ // Deleted by the PR, or renamed away. Not an error: the diff already shows the removal, and a
1047
+ // file that no longer exists has no "current contents" to attach.
1048
+ continue;
1049
+ }
1050
+ if (typeof text === 'string') candidates.push({ path, text, bytes: Buffer.byteLength(text, 'utf8') });
1051
+ }
1052
+
1053
+ // Smallest first: for a fixed budget this attaches the most FILES, and the finding class being
1054
+ // fixed is "the reviewer could not see file X at all" — breadth beats depth.
1055
+ candidates.sort((a, b) => a.bytes - b.bytes || a.path.localeCompare(b.path));
1056
+
1057
+ const attached = [];
1058
+ const omitted = [];
1059
+ let used = 0;
1060
+ for (const file of candidates) {
1061
+ const framed = file.bytes + file.path.length + 24; // fence + heading overhead
1062
+ if (attached.length >= maxFiles || used + framed > budgetBytes) {
1063
+ omitted.push(file.path);
1064
+ continue;
1065
+ }
1066
+ attached.push(file);
1067
+ used += framed;
1068
+ }
1069
+
1070
+ return { attached, omitted, bytes: used };
1071
+ }
1072
+
1073
+ /** Render what buildFileContext selected, including the honest manifest. Empty string when nothing fit. */
1074
+ export function renderFileContext({ attached, omitted }) {
1075
+ if (attached.length === 0 && omitted.length === 0) return '';
1076
+ const lines = [
1077
+ '## Current full contents of the files this diff touches',
1078
+ '',
1079
+ 'A diff shows changed lines and a few of context. These are the COMPLETE current files, so you',
1080
+ 'can check whether a symbol is defined elsewhere in the file, or imported from somewhere the',
1081
+ 'diff does not show, before reporting it as missing.',
1082
+ '',
1083
+ ];
1084
+ if (omitted.length) {
1085
+ lines.push(
1086
+ `**${omitted.length} file(s) did NOT fit the size budget and are not below. Their absence here says` +
1087
+ ` nothing about whether they exist:** ${omitted.join(', ')}.`,
1088
+ ''
1089
+ );
1090
+ }
1091
+ for (const file of attached) {
1092
+ lines.push(`### ${file.path}`, '', '```', file.text, '```', '');
1093
+ }
1094
+ return lines.join('\n');
1095
+ }
1096
+
502
1097
  // agy 1.0.10 print mode: --model is REQUIRED (or `--print` exits 0 with NO output), and a quota-exhausted /
503
1098
  // unreachable model ALSO exits 0 with empty stdout (the 429 lands only in agy's log). So an empty result is a
504
1099
  // real failure, not success — we try AGY_MODEL first and, on empty, retry once with AGY_FALLBACK_MODEL (a
@@ -519,19 +1114,47 @@ export function runAntigravity(fullArgv, opts = {}, deps = {}) {
519
1114
  // Default: the review pair above — existing callers are byte-identical in behavior.
520
1115
  const modelPair = opts.models?.length
521
1116
  ? [...new Set(opts.models)]
522
- : AGY_MODEL === AGY_FALLBACK_MODEL ? [AGY_MODEL] : [AGY_MODEL, AGY_FALLBACK_MODEL];
1117
+ : AGY_MODEL === AGY_FALLBACK_MODEL
1118
+ ? [AGY_MODEL]
1119
+ : [AGY_MODEL, AGY_FALLBACK_MODEL];
523
1120
  const tried = [];
524
1121
  for (const model of modelPair) {
525
1122
  const r = execAgy(fullArgv, model, spawn);
526
1123
  if (r.status !== 0) {
527
- // A non-zero exit is a real agy error (bad flags, crash) — NOT the quota signal — so don't burn the
528
- // fallback on it; surface it directly.
529
1124
  const last = (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'unknown error';
1125
+ // A non-zero exit is USUALLY a real agy error (bad flags, crash) — surface those directly
1126
+ // rather than burning the fallback on them.
1127
+ //
1128
+ // But not always, and this cost a real run to learn (2026-07-25, commit-report's first live
1129
+ // use after merge): `gpt-oss-120b-medium` answered "Our servers are experiencing high traffic
1130
+ // right now, please try again in a minute" with a NON-ZERO exit. That is precisely the
1131
+ // situation the second model exists for — a different provider with a separate capacity pool
1132
+ // — and the old branch refused to try it, because the fallback was wired only to the
1133
+ // EMPTY-output signal. Same transient condition, different exit code, no fallback.
1134
+ // Classify on the FULL output, not on `last`. `last` is only the final non-empty stderr line,
1135
+ // chosen because it makes the best human-readable message — but agy can emit the transient
1136
+ // notice followed by a stack trace or a trailing status line, which would push the phrase we
1137
+ // match on out of view and abort instead of falling back (cross-review, PR #29). Both streams
1138
+ // are checked because agy is already known to split diagnostics across them inconsistently —
1139
+ // the same reason isContextWindowOverflow is called on stdout and stderr above.
1140
+ const failureOutput = `${r.stderr || ''}\n${r.stdout || ''}`;
1141
+ if (isTransientAgyError(failureOutput) && model !== modelPair[modelPair.length - 1]) {
1142
+ warn(`⚠ agy "${model}" is temporarily unavailable (${last}) → trying the fallback model.`);
1143
+ tried.push(model);
1144
+ continue;
1145
+ }
530
1146
  return fail(opts.soft, `agy -p failed (model "${model}"): ${last}`);
531
1147
  }
532
1148
  const out = (r.stdout || '').trim();
533
1149
  if (out) {
534
- if (model !== modelPair[0]) warn(`⚠ agy "${modelPair[0]}" returned no output (quota/unavailable?) → used "${model}".`);
1150
+ if (model !== modelPair[0])
1151
+ warn(`⚠ agy "${modelPair[0]}" returned no output (quota/unavailable?) → used "${model}".`);
1152
+ // Tell the caller WHICH model answered. Added because the prose rail's footer said "agy",
1153
+ // which cannot distinguish the primary from the fallback — so a silent switch between two
1154
+ // models with very different registers was unattributable in the channel, and that is exactly
1155
+ // the confusion that took a human to notice. Optional and side-effect-only, so every existing
1156
+ // caller keeps its byte-identical string return.
1157
+ opts.onModel?.(model);
535
1158
  return out;
536
1159
  }
537
1160
  tried.push(model);
@@ -544,11 +1167,87 @@ export function runAntigravity(fullArgv, opts = {}, deps = {}) {
544
1167
  );
545
1168
  }
546
1169
 
1170
+ // ── Mistral Vibe (`vibe`) ────────────────────────────────────────────────────────────────────────────────
1171
+ // Vibe's non-interactive path is `--prompt` ("programmatic mode"): it skips the Textual chat UI, disables
1172
+ // interactive tools, and exits when done. Three flags matter for a reviewer and each is deliberate:
1173
+ //
1174
+ // --agent plan Vibe ships several approval agents (default · plan · accept-edits · auto-approve) and
1175
+ // programmatic mode DEFAULTS TO `auto-approve` — i.e. a reviewer that could silently edit
1176
+ // the working tree. `plan` is the read-only one. This is not a nicety: an advisory review
1177
+ // that mutates the repo it is reviewing is the exact failure the "advisory only" contract
1178
+ // exists to prevent, so the flag is passed unconditionally and is not overridable by env.
1179
+ // --max-turns Vibe's own docs recommend bounding run length. A single-pass review needs very few.
1180
+ // --output text json/streaming are for structured consumers; we want the prose findings on stdout.
1181
+ // --trust Programmatic mode still ENFORCES folder trust but cannot show the confirmation prompt,
1182
+ // so an untrusted directory would otherwise stall or refuse. `--trust` grants trust for
1183
+ // this invocation only (it does not write ~/.vibe/trusted_folders.toml).
1184
+ //
1185
+ // ⚠ CONTRACT NOT YET PROBED AGAINST A LIVE BINARY. Everything above comes from Mistral's published CLI
1186
+ // docs, not from a run on this machine — and this repo's own rule is that a roster is PROBED, never
1187
+ // assumed (a CLI that self-reports a capability it does not have has burned us before; see the agy
1188
+ // version pin above, where a silent `--print` contract change shipped empty reviews for weeks). Before
1189
+ // relying on vibe in a real review, run the probe once and record the result here:
1190
+ //
1191
+ // node scripts/cross-review.mjs <PR#> --agent vibe --dry-run
1192
+ //
1193
+ // A green probe prints real findings. An EMPTY result is a FAILURE, not a pass — see runVibe below, which
1194
+ // treats empty stdout as an error exactly like the agy path does, for the same reason (a quota-capped or
1195
+ // misconfigured CLI can exit 0 with nothing).
1196
+ // vibe-probe: verified 2026-08-25 against vibe 2.24.2 — `node scripts/cross-review.mjs 118 --agent vibe
1197
+ // --builder claude --code-only --dry-run` returned a real, substantive review (Blocking: None,
1198
+ // Should-fix: None, and one Nit that independently spotted an unrelated version-pin commit riding
1199
+ // the branch). NOT empty, which is the failure this probe exists to catch. The four flags above
1200
+ // (--agent plan / --max-turns / --output text / --trust) behaved as the docs describe.
1201
+ //
1202
+ // `vibe-acp` is the WRONG entry point for this use case and is deliberately not wired: it starts a
1203
+ // JSON-RPC server that speaks the Agent Client Protocol over stdio for IDE extensions (Zed et al.). It
1204
+ // expects a long-lived client session, not a one-shot prompt, so a script would have to implement an ACP
1205
+ // client to get a single review out of it. `vibe --prompt` is the scripting/CI path, and it is the one
1206
+ // Mistral documents for exactly this.
1207
+ export const VIBE_ARG_LIMIT = 256 * 1024;
1208
+ // 4 → 12 (2026-08-07) → 24 (the template's live-probed budget). With every tool disabled a turn cannot
1209
+ // be spent on a tool call, so a higher ceiling costs nothing when it is not needed; the alternative is an
1210
+ // intermittently missing review. `--max-price` / `--max-tokens` exist if a real cost bound is ever wanted.
1211
+ export const VIBE_MAX_TURNS = process.env.VIBE_MAX_TURNS || '24';
1212
+ // The reviewer's ENTIRE toolset: nothing. Kept as an exported (empty) list so the argv contract stays a
1213
+ // named, tested fact rather than a literal buried in runVibe.
1214
+ //
1215
+ // ── Why the read-only allow-list was removed (distribute-what-we-use D1, 2026-09-29) ─────────────────
1216
+ // From 2026-08-07 this was ['read_file', 'grep'] with `--auto-approve`, which cured a blind reviewer.
1217
+ // a consuming project's probe of 2026-08-08 (vibe 2.23.3) found why that is unsafe: vibe checks auto-approval
1218
+ // BEFORE its sensitive-file and outside-workdir prompts, and `read_file` takes absolute paths. A malicious
1219
+ // PR diff could therefore instruct the reviewer to read `.env.local` or a host key into its findings,
1220
+ // which cross-review then POSTS as a comment — on a public repo, to anyone. Where two copies of this
1221
+ // rail disagreed on a safety property, the stricter one wins.
1222
+ //
1223
+ // The blindness this once cured is cured differently now: cross-review attaches the touched files'
1224
+ // head-side contents (buildFileContext) to the prompt, so the reviewer sees the surrounding code without
1225
+ // being able to ask for any path it likes.
1226
+ export const VIBE_READ_ONLY_TOOLS = Object.freeze([]);
1227
+ // Optional: pin a model with `VIBE_MODEL`. Left unset by default so vibe uses the account's configured
1228
+ // default — unlike agy, an unset model here is not known to blank the output.
1229
+ export const VIBE_MODEL = process.env.VIBE_MODEL || null;
1230
+
1231
+ // ── Claude Code (`claude`) as a plain CLI reviewer ───────────────────────────────────────────────────────
1232
+ // Sonnet by default: review is a single read of a bounded diff, and the routing policy reserves the
1233
+ // strongest model for the *fresh subagent* pass on high-risk work (see review-route.mjs). Override with
1234
+ // CLAUDE_REVIEW_MODEL.
1235
+ //
1236
+ // The invocation is deliberately tool-less. `--tools ""` removes every built-in tool and
1237
+ // `--strict-mcp-config` (with no `--mcp-config`) loads no MCP servers, so the reviewer can do exactly one
1238
+ // thing: read the diff piped to it on stdin and write findings to stdout. It cannot edit the tree it is
1239
+ // reviewing, cannot shell out, and cannot wander into unrelated files — the same "single pass, advisory,
1240
+ // no writes" contract the other three CLIs get, enforced by flags rather than by hope.
1241
+ // `--bare` skips hook/skill/plugin/MCP/CLAUDE.md auto-discovery so a scripted call starts fast and, more
1242
+ // importantly, so the review is not silently reshaped by whatever config the host repo happens to carry.
1243
+ export const CLAUDE_REVIEW_MODEL = process.env.CLAUDE_REVIEW_MODEL || 'sonnet';
1244
+
547
1245
  // One `vibe --prompt "<prompt+context>" --agent plan --output text` invocation. Like agy, vibe takes the
548
1246
  // whole thing as an argv string, so the same size cap applies (and for the same reason: a clear message
549
- // beats an opaque E2BIG). `--agent plan` is NOT optional. `--disabled-tools '*'` is equally non-optional:
550
- // plan mode describes intended behavior, while the tool filter enforces that an injected diff cannot read
551
- // host files, shell out or mutate anything. All review context is already embedded in `fullArgv`.
1247
+ // beats an opaque E2BIG). `--agent plan` is NOT optional, and `--disabled-tools '*'` is equally
1248
+ // non-optional: plan mode describes intended behaviour, while the tool filter enforces that an injected
1249
+ // diff cannot read host files, shell out or mutate anything (see VIBE_READ_ONLY_TOOLS for why the
1250
+ // read-only allow-list this used to carry was removed). No `--auto-approve`: there is nothing to approve.
552
1251
  //
553
1252
  // Empty stdout is treated as a FAILURE, not as "no findings". Every CLI on this roster can exit 0 having
554
1253
  // produced nothing when it is quota-capped or misconfigured, and a review that silently becomes empty is
@@ -587,6 +1286,8 @@ export function runVibe(fullArgv, opts = {}, deps = {}) {
587
1286
  );
588
1287
  if (r.status !== 0) {
589
1288
  const detail = lastLine(r.stderr) || lastLine(r.stdout);
1289
+ // Name the remedy for the one failure that is ours, not the CLI's. A turn-limit stop means the
1290
+ // agent ran out of budget mid-review; it is not a quota cap and re-running rarely helps.
590
1291
  const hint = /turn limit/i.test(`${r.stderr || ''}${r.stdout || ''}`)
591
1292
  ? ` — the agent ran out of turns, not quota. Raise VIBE_MAX_TURNS (currently ${VIBE_MAX_TURNS}) and re-run.`
592
1293
  : '';
@@ -601,34 +1302,63 @@ export function runVibe(fullArgv, opts = {}, deps = {}) {
601
1302
  `\`vibe --prompt "say OK" --output text --trust\`; if that is empty too, re-authenticate. ` +
602
1303
  `(An empty result is a failure, never "no findings" — see runVibe's header.)`
603
1304
  );
604
- // With tools disabled, Vibe can still emit a literal tool request as its
605
- // final text. That is an unfinished attempt, not review findings; accepting
606
- // and posting it would turn a failed pass into a confident green-looking one.
607
- if (/^(?:read_file|grep|list_directory|shell|run_command|edit_file|write_file|apply_patch)\s*\{[\s\S]*\}$/.test(out)) {
1305
+ // ── A TRUNCATED review is a failure too, and it is the dangerous shape ────────────────────────
1306
+ // On PR #121 vibe exhausted its turn budget mid-read and exited 0 with a bare tool call as its
1307
+ // entire output — `read_file{"file_path": …}`. Both guards above passed (status 0, output
1308
+ // non-empty), so the runner posted it to the PR, where it renders as a review with no findings.
1309
+ // An empty result at least LOOKS wrong; a truncated one reads as a clean pass and quietly removes
1310
+ // a family from the gate.
1311
+ //
1312
+ // Detected by shape rather than by scanning for an error string: a real review always contains a
1313
+ // findings heading, because the prompt demands one. Anything that does not is not a review.
1314
+ if (isTruncatedReview(out)) {
608
1315
  return fail(
609
1316
  opts.soft,
610
- 'vibe requested a disabled tool and no review was produced — re-run the bounded diff; never count this output as a pass.'
1317
+ `vibe requested a disabled tool or stopped between turns — no review was produced (its output is a ` +
1318
+ `bare tool call, or has no findings section). Re-run; if it recurs, raise VIBE_MAX_TURNS ` +
1319
+ `(currently ${VIBE_MAX_TURNS}). Posting this would look like a clean pass.`
611
1320
  );
612
1321
  }
613
1322
  return out;
614
1323
  }
615
1324
 
1325
+ /**
1326
+ * Does this look like a stopped-mid-review rather than a review?
1327
+ *
1328
+ * Two signals, both cheap and neither dependent on the model's wording:
1329
+ * 1. the output STARTS with a tool call (`read_file{…}`, `grep {…}`) — the literal shape vibe emits
1330
+ * when it is cut off between turns, or when it asks for a tool that is disabled;
1331
+ * 2. it never mentions Blocking / Should-fix / Nit, which the review prompt requires.
1332
+ *
1333
+ * Exported for the unit layer: this is the guard whose absence let a non-review reach a PR.
1334
+ */
1335
+ export function isTruncatedReview(out) {
1336
+ const text = String(out || '').trim();
1337
+ if (/^\w+\s*\{/.test(text)) return true;
1338
+ return !/\b(blocking|should-fix|nit)\b/i.test(text);
1339
+ }
1340
+
616
1341
  // One `claude -p "<prompt>"` invocation with the context piped on stdin (same shape as codex, which is why
617
1342
  // cross-review can hand both runners the identical prompt/stdin pair). Tool-less and MCP-less by
618
1343
  // construction — see CLAUDE_REVIEW_MODEL's header for why each flag is there.
619
1344
  //
620
- // NOTE ON SAME-FAMILY REVIEW: nothing in this function knows or cares who built the diff. Routing a
621
- // Claude-built diff to a Claude reviewer would be a same-family pass wearing a cross-family label, and
622
- // preventing that is `review-route.mjs`'s job (rule 1: a family never reviews its own diff). Calling this
623
- // directly with `--agent claude` on a Claude-authored PR is a deliberate act, and the caller owns it.
1345
+ // NOTE ON SAME-FAMILY REVIEW: nothing in this function knows who built the diff. The refusal lives one
1346
+ // level up: review-route never routes a family to its own diff, and cross-review refuses `--agent claude`
1347
+ // when `--builder claude` is stated (checkReviewerPairing). Without a stated builder, the caller owns it.
1348
+ //
1349
+ // AUTH NOTE: `--bare` skips keychain/OAuth reads, so this path authenticates with ANTHROPIC_API_KEY.
624
1350
  export function runClaudeCode(prompt, stdin, opts = {}, deps = {}) {
625
1351
  const { spawn = spawnSync } = deps;
626
1352
  const args = [
627
- '-p', prompt,
628
- '--model', CLAUDE_REVIEW_MODEL,
629
- '--tools', '',
1353
+ '-p',
1354
+ prompt,
1355
+ '--model',
1356
+ CLAUDE_REVIEW_MODEL,
1357
+ '--tools',
1358
+ '',
630
1359
  '--strict-mcp-config',
631
- '--output-format', 'text',
1360
+ '--output-format',
1361
+ 'text',
632
1362
  '--no-session-persistence',
633
1363
  '--bare',
634
1364
  ];
@@ -638,7 +1368,7 @@ export function runClaudeCode(prompt, stdin, opts = {}, deps = {}) {
638
1368
  return fail(
639
1369
  opts.soft,
640
1370
  `claude not found or failed to spawn (${r.error.message}) — install Claude Code ` +
641
- `(https://claude.com/claude-code) and run \`claude auth login\`, or use --agent codex/antigravity.`
1371
+ `(https://claude.com/claude-code) and set ANTHROPIC_API_KEY (this path runs \`--bare\`), or use --agent codex/antigravity.`
642
1372
  );
643
1373
  if (r.status !== 0) return fail(opts.soft, `claude -p failed: ${lastLine(r.stderr)}`);
644
1374
 
@@ -646,28 +1376,27 @@ export function runClaudeCode(prompt, stdin, opts = {}, deps = {}) {
646
1376
  if (!out)
647
1377
  return fail(
648
1378
  opts.soft,
649
- `claude returned no output — likely a usage cap or an expired session. Check \`claude auth status\`, ` +
1379
+ `claude returned no output — likely a usage cap or a missing ANTHROPIC_API_KEY (this path runs \`--bare\`), ` +
650
1380
  `or use --agent codex/antigravity/vibe. (An empty result is a failure, never "no findings".)`
651
1381
  );
652
1382
  return out;
653
1383
  }
654
1384
 
655
- // ── Devin — a PROSE WRITER here, not a reviewer (independent quota pool) ─────────────────────────────────
656
- // Ported with the reporting family (plugin-audit-and-extraction S1): lib/prose-writer.mjs drafts with
657
- // Devin first. It is deliberately NOT in AGENTS/AGENT_BIN — the reviewer roster is review-route.mjs's
658
- // decision, and this port adds a writer, not a reviewer.
1385
+ // ── Devin — a PROSE WRITER here, never a reviewer (independent quota pool) ────────────────────────────────
1386
+ // lib/prose-writer.mjs drafts with Devin first (plugin-audit-and-extraction S1). It is deliberately NOT in
1387
+ // AGENTS, so `cross-review.mjs --agent devin` is refused. A consuming project used it as a third review pool;
1388
+ // distribute-what-we-use S1's security lens found why that is unsafe: `devin -p` auto-approves read-only
1389
+ // tools (`--permission-mode auto`), there is no flag that disables them, and even `--sandbox` "can read
1390
+ // everything except paths hidden by Deny(Read(...)) rules" (`devin --help`, 3000.11.3). A reviewer fed an
1391
+ // attacker's PR diff could be steered into reading `.env.local` into a comment posted on the PR — the same
1392
+ // hole closed for Vibe (see VIBE_READ_ONLY_TOOLS). Its prose drafts are fed this repo's own docs and git log.
659
1393
  // `devin -p --prompt-file <file>` runs non-interactively and prints the response. The prompt (framing +
660
1394
  // diff) rides in a FILE, not argv, so there is NO size cap to guard — the whole reason to prefer prompt-file
661
1395
  // over agy's argv path for large diffs. Empty stdout is a failure (a quota-capped devin, like agy, exits 0
662
1396
  // with nothing). Uses the account default model. `deps` is injectable so a node:test drives it without a
663
1397
  // real devin binary or touching the real filesystem.
664
1398
  export function runDevin(prompt, opts = {}, deps = {}) {
665
- const {
666
- spawn = spawnSync,
667
- writeFile = writeFileSync,
668
- mkdtemp = mkdtempSync,
669
- rm = rmSync,
670
- } = deps;
1399
+ const { spawn = spawnSync, writeFile = writeFileSync, mkdtemp = mkdtempSync, rm = rmSync } = deps;
671
1400
  let dir;
672
1401
  try {
673
1402
  dir = mkdtemp(join(tmpdir(), 'xrev-devin-'));
@@ -677,19 +1406,33 @@ export function runDevin(prompt, opts = {}, deps = {}) {
677
1406
  const file = join(dir, 'prompt.md');
678
1407
  try {
679
1408
  writeFile(file, prompt, 'utf8');
680
- const r = spawn('devin', ['-p', '--prompt-file', file], { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
681
- if (r.error) return fail(opts.soft, `devin not found or failed to spawn (${r.error.message}) — install the Devin CLI or use --agent codex/antigravity.`);
1409
+ const r = spawn('devin', ['-p', '--prompt-file', file], {
1410
+ encoding: 'utf8',
1411
+ maxBuffer: 64 * 1024 * 1024,
1412
+ });
1413
+ if (r.error)
1414
+ return fail(
1415
+ opts.soft,
1416
+ `devin not found or failed to spawn (${r.error.message}) — install the Devin CLI or use --agent codex/antigravity.`
1417
+ );
682
1418
  if (r.status !== 0) {
683
1419
  const last = (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'unknown error';
684
1420
  return fail(opts.soft, `devin -p failed: ${last}`);
685
1421
  }
686
1422
  const out = (r.stdout || '').trim();
687
1423
  if (!out) {
688
- return fail(opts.soft, `devin returned no output — likely a quota cap or auth lapse (run \`devin auth\`), or use --agent codex/antigravity.`);
1424
+ return fail(
1425
+ opts.soft,
1426
+ `devin returned no output — likely a quota cap or auth lapse (run \`devin auth\`), or use --agent codex/antigravity.`
1427
+ );
689
1428
  }
690
1429
  return out;
691
1430
  } finally {
692
1431
  // Best-effort cleanup — a leaked temp prompt file must never fail the review.
693
- try { rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
1432
+ try {
1433
+ rm(dir, { recursive: true, force: true });
1434
+ } catch {
1435
+ /* ignore */
1436
+ }
694
1437
  }
695
1438
  }