@golden-frijoles/kit 0.5.4 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-state.mjs +716 -0
- package/dist/cross-agent-doctor.mjs +617 -0
- package/dist/cross-review.mjs +857 -0
- package/dist/cross-review.prompt.md +100 -0
- package/dist/cross-review.security.prompt.md +92 -0
- package/dist/lib/cross-agent-cli.mjs +886 -143
- package/dist/lib/review-guard.mjs +478 -0
- package/dist/lib/secret-guard.mjs +212 -0
- package/dist/lib/session-journal.mjs +99 -0
- package/dist/review-config.json +25 -0
- package/dist/review-route.mjs +234 -0
- package/dist/session-note.mjs +127 -0
- package/dist/session-resume.mjs +953 -0
- package/package.json +1 -1
|
@@ -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
|
-
// (
|
|
9
|
-
//
|
|
10
|
-
//
|
|
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
|
|
53
|
-
//
|
|
54
|
-
|
|
55
|
-
|
|
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-
|
|
70
|
-
// ^ machine-managed marker — `node scripts/
|
|
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.
|
|
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
|
-
//
|
|
82
|
-
//
|
|
83
|
-
//
|
|
84
|
-
//
|
|
85
|
-
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
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
|
-
//
|
|
92
|
-
//
|
|
93
|
-
|
|
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
|
-
//
|
|
96
|
-
//
|
|
97
|
-
//
|
|
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
|
-
//
|
|
100
|
-
//
|
|
101
|
-
//
|
|
102
|
-
//
|
|
103
|
-
|
|
104
|
-
//
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
//
|
|
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
|
-
// `
|
|
124
|
-
//
|
|
125
|
-
//
|
|
126
|
-
//
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
|
142
|
-
//
|
|
143
|
-
//
|
|
144
|
-
//
|
|
145
|
-
//
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
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 {
|
|
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 =
|
|
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/
|
|
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
|
-
|
|
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({
|
|
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
|
|
431
|
-
// model wouldn't help — the input itself is too big
|
|
432
|
-
//
|
|
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
|
-
|
|
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
|
|
440
|
-
//
|
|
441
|
-
|
|
442
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
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])
|
|
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
|
|
550
|
-
// plan mode describes intended
|
|
551
|
-
// host files, shell out or mutate anything
|
|
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
|
-
//
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
|
|
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
|
-
|
|
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
|
|
621
|
-
//
|
|
622
|
-
//
|
|
623
|
-
//
|
|
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',
|
|
628
|
-
|
|
629
|
-
'--
|
|
1353
|
+
'-p',
|
|
1354
|
+
prompt,
|
|
1355
|
+
'--model',
|
|
1356
|
+
CLAUDE_REVIEW_MODEL,
|
|
1357
|
+
'--tools',
|
|
1358
|
+
'',
|
|
630
1359
|
'--strict-mcp-config',
|
|
631
|
-
'--output-format',
|
|
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
|
|
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
|
|
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,
|
|
656
|
-
//
|
|
657
|
-
//
|
|
658
|
-
//
|
|
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], {
|
|
681
|
-
|
|
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(
|
|
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 {
|
|
1432
|
+
try {
|
|
1433
|
+
rm(dir, { recursive: true, force: true });
|
|
1434
|
+
} catch {
|
|
1435
|
+
/* ignore */
|
|
1436
|
+
}
|
|
694
1437
|
}
|
|
695
1438
|
}
|