@golden-frijoles/kit 0.0.0 → 0.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +25 -1
- package/bin.mjs +108 -0
- package/dist/LICENSE +202 -0
- package/dist/NOTICE +7 -0
- package/dist/babysit-pr.mjs +213 -0
- package/dist/build-order-sync.mjs +141 -0
- package/dist/build-order.mjs +178 -0
- package/dist/cross-panel.mjs +216 -0
- package/dist/cross-panel.prompt.md +115 -0
- package/dist/doc-format.enforced.json +4 -0
- package/dist/doc-format.mjs +856 -0
- package/dist/doc-hygiene.mjs +279 -0
- package/dist/init.mjs +132 -0
- package/dist/lib/cross-agent-cli.mjs +695 -0
- package/dist/lib/gh-rest.mjs +229 -0
- package/dist/lib/golden-onboarding.mjs +204 -0
- package/dist/lib/jev.mjs +334 -0
- package/dist/lib/log-branch.mjs +106 -0
- package/dist/lib/pmo-benchmarks.mjs +61 -0
- package/dist/lib/pmo-delivery.mjs +102 -0
- package/dist/lib/pmo-metrics.mjs +200 -0
- package/dist/lib/pmo-templates.mjs +132 -0
- package/dist/lib/pmo-window-log.mjs +99 -0
- package/dist/lib/project-root.mjs +78 -0
- package/dist/lib/prose-brief.mjs +191 -0
- package/dist/lib/prose-guard.mjs +761 -0
- package/dist/lib/prose-writer.mjs +410 -0
- package/dist/lib/report-registry.mjs +469 -0
- package/dist/lib/reporting-config.mjs +266 -0
- package/dist/lib/roadmap-contract.mjs +305 -0
- package/dist/lib/roadmap-status-buckets.mjs +20 -0
- package/dist/lib/standup-deck.mjs +120 -0
- package/dist/lib/telegram-format.mjs +91 -0
- package/dist/live-smoke.mjs +303 -0
- package/dist/pmo/benchmarks.json +77 -0
- package/dist/pmo/templates/metrics-sheet.md +27 -0
- package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
- package/dist/pmo/templates/weekly-story-deck.md +66 -0
- package/dist/pmo-report.mjs +338 -0
- package/dist/preflight.mjs +444 -0
- package/dist/prose/cpo-persona.md +108 -0
- package/dist/prose/internal.task.md +48 -0
- package/dist/prose/standup.task.md +38 -0
- package/dist/prose/weekly.task.md +36 -0
- package/dist/prose-draft.mjs +198 -0
- package/dist/prose-lessons.md +59 -0
- package/dist/roadmap-extract.mjs +410 -0
- package/dist/skeleton/Roadmap/00-ideas/README.md +85 -0
- package/dist/skeleton/Roadmap/00-ideas/audits/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/00-ideas/seeds/.gitkeep +0 -0
- package/dist/skeleton/Roadmap/LEARNINGS.md +164 -0
- package/dist/skeleton/Roadmap/README.md +67 -0
- package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +233 -0
- package/dist/standup/templates/daily-story-deck.md +46 -0
- package/dist/standup.mjs +634 -0
- package/dist/vercel-prune-previews.mjs +164 -0
- package/dist/weekly-recap.mjs +526 -0
- package/package.json +33 -1
|
@@ -0,0 +1,695 @@
|
|
|
1
|
+
// cross-agent-cli.mjs — the shared CLI plumbing for the cross-agent second-opinion tools.
|
|
2
|
+
//
|
|
3
|
+
// One source of truth for driving a DIFFERENT model family's CLI, reused by both:
|
|
4
|
+
// • scripts/cross-review.mjs — advisory second opinion on a PR diff
|
|
5
|
+
// • scripts/cross-panel.mjs — advisory second opinion on a proposed plan (a scope/seed doc)
|
|
6
|
+
//
|
|
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.
|
|
29
|
+
//
|
|
30
|
+
// ── Codex → Antigravity auto-fallback ───────────────────────────────────────────────────────────────────
|
|
31
|
+
//
|
|
32
|
+
// When the local Codex token has lapsed, `codex exec` exits non-zero with an AUTH error on stderr (e.g.
|
|
33
|
+
// "Your session has ended. Please log in again." / "refresh token was revoked" / 401). `runWithCodexFallback`
|
|
34
|
+
// detects that specific auth signal (NOT every error — a non-auth break or an empty diff still fails clearly)
|
|
35
|
+
// and retries once with Antigravity, returning `{ fellBack: true, from: 'codex', to: 'antigravity' }` so the
|
|
36
|
+
// caller can label the output. The trigger lives in the pure, testable `decideCodexFallback`; restoring Codex
|
|
37
|
+
// is `codex login` (see scripts/README.md → "Restoring a lapsed Codex token").
|
|
38
|
+
|
|
39
|
+
import { spawnSync } from 'node:child_process';
|
|
40
|
+
import { readFileSync, existsSync, writeFileSync, mkdtempSync, rmSync } from 'node:fs';
|
|
41
|
+
import { tmpdir } from 'node:os';
|
|
42
|
+
import { join } from 'node:path';
|
|
43
|
+
|
|
44
|
+
// label per agent. Drives both the CLI dispatch and the human-readable header.
|
|
45
|
+
export const AGENTS = {
|
|
46
|
+
codex: 'Codex',
|
|
47
|
+
antigravity: 'Antigravity',
|
|
48
|
+
vibe: 'Mistral Vibe',
|
|
49
|
+
claude: 'Claude Code',
|
|
50
|
+
};
|
|
51
|
+
|
|
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' };
|
|
56
|
+
|
|
57
|
+
// Antigravity's headless CLI is new and its print contract shifts between releases — pin the known-good
|
|
58
|
+
// version and FAIL LOUD on a mismatch (checkAgyVersion). 1.0.7→1.0.10 silently changed `--print` so it
|
|
59
|
+
// emits NOTHING without an explicit --model, which a soft warn let ship as empty reviews; a hard fail forces
|
|
60
|
+
// a human to re-verify the invocation below and bump this deliberately. Bumping = re-test runAntigravity().
|
|
61
|
+
//
|
|
62
|
+
// Re-verified 2026-07-03 against 1.0.16 (jump from 1.0.10; changelogs 1.0.11-1.0.16 show no print/--model
|
|
63
|
+
// changes): `agy -p "<prompt>" --model "<valid model>"` still exits 0 with real stdout — the exact call
|
|
64
|
+
// runAntigravity makes. One thing DID get more lenient: omitting --model, or passing an unrecognized model
|
|
65
|
+
// name, no longer prints nothing — agy now silently substitutes a default model and still returns output.
|
|
66
|
+
// Harmless here since AGY_MODEL/AGY_FALLBACK_MODEL below are always valid, listed model names (checked via
|
|
67
|
+
// `agy models`), but it means a future typo in either constant would silently review with the WRONG model
|
|
68
|
+
// 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
|
|
71
|
+
// below) after a green live contract probe. Don't hand-edit the marker's shape.
|
|
72
|
+
export const AGY_PINNED = '1.2.5';
|
|
73
|
+
|
|
74
|
+
// agy's `--print` mode prints NOTHING unless `--model` names a model — and, crucially, it ALSO prints
|
|
75
|
+
// nothing (exit 0, empty stdout — the error lands only in agy's log, see --log-file) when the model is
|
|
76
|
+
// quota-exhausted or unreachable. Gemini is the ideal reviewer (a different family from BOTH the Claude host
|
|
77
|
+
// and the GPT-family codex), so it's the default — but its per-subscription quota is tight and exhausts
|
|
78
|
+
// ("RESOURCE_EXHAUSTED 429: Individual quota reached"), so runAntigravity AUTO-FALLS-BACK to
|
|
79
|
+
// AGY_FALLBACK_MODEL (GPT-OSS, a separate quota pool that worked on the dev machine) when the primary yields
|
|
80
|
+
// 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.
|
|
89
|
+
export const AGY_FALLBACK_MODEL = process.env.AGY_FALLBACK_MODEL || 'gpt-oss-120b-medium';
|
|
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;
|
|
94
|
+
|
|
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:
|
|
98
|
+
//
|
|
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).
|
|
109
|
+
//
|
|
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:
|
|
115
|
+
//
|
|
116
|
+
// node scripts/cross-review.mjs <PR#> --agent vibe --dry-run
|
|
117
|
+
//
|
|
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>`.
|
|
122
|
+
//
|
|
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;
|
|
135
|
+
|
|
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.
|
|
140
|
+
//
|
|
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';
|
|
149
|
+
|
|
150
|
+
export function die(msg) {
|
|
151
|
+
process.stderr.write(`✗ ${msg}\n`);
|
|
152
|
+
process.exit(1);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export function need(val, flag) {
|
|
156
|
+
if (val === undefined || val.startsWith('-')) die(`${flag} requires a value`);
|
|
157
|
+
return val;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// `cmd --version` exits non-zero only when the binary is missing (spawn .error) — a clean presence check.
|
|
161
|
+
export function ensureCmd(cmd, fix) {
|
|
162
|
+
const r = spawnSync(cmd, ['--version'], { encoding: 'utf8' });
|
|
163
|
+
if (r.error) die(fix);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Non-fatal sibling of ensureCmd: true if the binary is on PATH, false otherwise. Used by the fallback to
|
|
167
|
+
// decide whether Antigravity is even available before retrying — no die().
|
|
168
|
+
export function hasCmd(cmd) {
|
|
169
|
+
return !spawnSync(cmd, ['--version'], { encoding: 'utf8' }).error;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function ensureGh() {
|
|
173
|
+
ensureCmd('gh', 'gh not found — install GitHub CLI (https://cli.github.com), then `gh auth login`.');
|
|
174
|
+
const r = spawnSync('gh', ['auth', 'status'], { encoding: 'utf8' });
|
|
175
|
+
if (r.status !== 0) die('gh is not authenticated — run `gh auth login`.');
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ── Branch → PR resolution + stale-HEAD guard (the "wrong-branch tax" fix) ───────────────────────────────
|
|
179
|
+
// cross-review takes an explicit <PR#> but never ties it to the current branch or checks the head SHA, so
|
|
180
|
+
// the FIRST run regularly reviews the wrong or a stale diff and gets rerun. These helpers let the command
|
|
181
|
+
// resolve the PR from the branch and refuse a stale local HEAD. They live here (the shared rail) so the
|
|
182
|
+
// resolver is a single source of truth alongside the Codex-fallback plumbing both scripts already import.
|
|
183
|
+
|
|
184
|
+
// Module-local git I/O: returns trimmed stdout, or null on any non-zero/spawn error (a clean "couldn't").
|
|
185
|
+
function git(args) {
|
|
186
|
+
const r = spawnSync('git', args, { encoding: 'utf8' });
|
|
187
|
+
return r.status === 0 ? (r.stdout || '').trim() : null;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Module-local gh I/O returning the structured result (so callers/tests can read stderr to classify failure).
|
|
191
|
+
function ghJson(args) {
|
|
192
|
+
const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
|
|
193
|
+
return { ok: r.status === 0, stdout: r.stdout || '', stderr: r.stderr || '' };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// First 8 chars of a SHA, for human-readable guard messages. Null-safe → 'unknown' (never empty parens
|
|
197
|
+
// like `local HEAD ()` when `git rev-parse` couldn't read a SHA).
|
|
198
|
+
export function shortSha(sha) {
|
|
199
|
+
return sha ? sha.slice(0, 8) : 'unknown';
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Pure stale-HEAD decision — the unit under test (no I/O, like decideCodexFallback). Given the local HEAD
|
|
203
|
+
// SHA, the PR's head SHA, and whether --force was passed, decide whether to proceed:
|
|
204
|
+
// 'match' — SHAs equal → proceed silently.
|
|
205
|
+
// 'mismatch-force' — differ but --force → proceed with a warning.
|
|
206
|
+
// 'mismatch-block' — differ, no --force → refuse (review would be stale).
|
|
207
|
+
export function decideHeadGuard({ localHead, prHeadOid, force }) {
|
|
208
|
+
if (localHead && prHeadOid && localHead === prHeadOid) return 'match';
|
|
209
|
+
return force ? 'mismatch-force' : 'mismatch-block';
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// ── Cost guard: skip the review on a trivial diff ────────────────────────────────────────────────────────
|
|
213
|
+
// "Every PR" must not mean paying for a Codex pass on a typo or a docs-only change. This is the pure,
|
|
214
|
+
// testable decision (the unit under test, like decideHeadGuard) — the script fetches the changed-file stats
|
|
215
|
+
// (`gh pr view --json files`) and passes them here. Returns { skip, reason }.
|
|
216
|
+
//
|
|
217
|
+
// A file counts as docs/text (cheap to skip wholesale) when it matches DOC_FILE_RE. Otherwise we sum the
|
|
218
|
+
// changed lines (additions+deletions) and skip only below `minLines`. So a docs-only PR of any size skips,
|
|
219
|
+
// a tiny code tweak skips, but a real code change of any size is reviewed.
|
|
220
|
+
const DOC_FILE_RE = /(\.(md|mdx|txt|rst)$)|(^|\/)(LICENSE|CODEOWNERS|\.gitignore)$/i;
|
|
221
|
+
|
|
222
|
+
export function isDocFile(path) {
|
|
223
|
+
return DOC_FILE_RE.test(path || '') || /(^|\/)docs\//i.test(path || '');
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function decideTrivialSkip({ files, minLines = 10 } = {}) {
|
|
227
|
+
if (!Array.isArray(files) || files.length === 0) return { skip: true, reason: 'empty diff' };
|
|
228
|
+
if (files.every((f) => isDocFile(f.path))) return { skip: true, reason: 'docs-only diff' };
|
|
229
|
+
const lines = files.reduce((n, f) => n + (f.additions || 0) + (f.deletions || 0), 0);
|
|
230
|
+
if (lines < minLines)
|
|
231
|
+
return { skip: true, reason: `trivial diff (${lines} changed line${lines === 1 ? '' : 's'} < ${minLines})` };
|
|
232
|
+
return { skip: false };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ── Diff-size guard: strip generated files before they blow a reviewer's context window ────────────────────
|
|
236
|
+
// Found live (deploy-pipeline-tuning epic, 2026-07-11): a PR whose diff includes a large auto-generated file
|
|
237
|
+
// (a first-time-committed package-lock.json, ~12–19K lines) blew Codex's context window —
|
|
238
|
+
// `ERROR: Codex ran out of room in the model's context window` — and the failure surfaced as an opaque
|
|
239
|
+
// `codex exec failed (non-auth): 0` (the "0" is codex's own trailing token-count line, picked up by
|
|
240
|
+
// `lastLine()`, not a real exit code the caller can act on). Worked around by hand that day
|
|
241
|
+
// (`git diff origin/main...HEAD -- . ':(exclude)package-lock.json'` piped directly into `codex exec -`,
|
|
242
|
+
// bypassing this script); fixed here so the NEXT PR that touches a lockfile doesn't need the same manual
|
|
243
|
+
// detour. This repo committing per-app lockfiles is now Sprint 1's established convention, so this is a
|
|
244
|
+
// recurring case, not a one-off.
|
|
245
|
+
//
|
|
246
|
+
// Strips whole per-file diff hunks (each starts with `diff --git a/X b/Y`) for known generated-file
|
|
247
|
+
// basenames, replacing each with a one-line placeholder so the reviewer still sees THAT the file changed —
|
|
248
|
+
// just not its (often huge, low-signal) content. Pure string logic, no git/gh dependency, so it's directly
|
|
249
|
+
// unit-testable against a hand-built diff fixture.
|
|
250
|
+
// `jev-eval.fixtures.json` (jev-semantic-guards): recorded Jev answers that `jev-eval --live` rewrites — ~100 KB of
|
|
251
|
+
// 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)$/;
|
|
253
|
+
|
|
254
|
+
export function stripGeneratedFileDiffs(diffText, { extraPatterns = [] } = {}) {
|
|
255
|
+
if (!diffText) return { diff: diffText, strippedFiles: [] };
|
|
256
|
+
const isGenerated = (path) =>
|
|
257
|
+
GENERATED_FILE_RE.test(path || '') || extraPatterns.some((re) => re.test(path || ''));
|
|
258
|
+
|
|
259
|
+
// Split on the `diff --git a/X b/Y` boundary, keeping the delimiter with each chunk (lookahead split).
|
|
260
|
+
const chunks = diffText.split(/(?=^diff --git )/m);
|
|
261
|
+
const strippedFiles = [];
|
|
262
|
+
const kept = chunks.map((chunk) => {
|
|
263
|
+
const header = chunk.match(/^diff --git a\/(\S+) b\/(\S+)/);
|
|
264
|
+
if (!header) return chunk; // preamble before the first `diff --git` (rare, but don't drop it)
|
|
265
|
+
const path = header[2] || header[1];
|
|
266
|
+
if (!isGenerated(path)) return chunk;
|
|
267
|
+
strippedFiles.push(path);
|
|
268
|
+
return `diff --git a/${path} b/${path}\n(generated file — diff omitted to fit the reviewer's context window; see the real file in the PR)\n`;
|
|
269
|
+
});
|
|
270
|
+
return { diff: kept.join(''), strippedFiles };
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
// True when a codex/agy response signals it ran out of context-window room — a DIFFERENT failure class from
|
|
274
|
+
// an auth lapse (retrying with a fallback model would likely hit the exact same overflow, since the input
|
|
275
|
+
// size is the problem, not the model). Checked against both stdout and stderr since codex's own trailing
|
|
276
|
+
// diagnostics ("tokens used\n0") can land on either depending on the failure path, which is what made this
|
|
277
|
+
// failure mode read as an opaque `(non-auth): 0` before this check existed. Kept as a named, tested export so
|
|
278
|
+
// the message stays actionable instead of falling through to the generic non-auth failure text.
|
|
279
|
+
export function isContextWindowOverflow(output) {
|
|
280
|
+
// ['’] covers both a straight and a "smart"/curly apostrophe — CLIs are inconsistent about which they emit.
|
|
281
|
+
return /ran out of room in the model['’]?s context window/i.test(output || '');
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// gh stderr signalling "this branch has no associated PR" — kept TIGHT to gh's actual no-PR message so a
|
|
285
|
+
// repo/remote/auth misconfig (e.g. a bad --repo) falls through to the generic error instead of being masked
|
|
286
|
+
// as "no open PR" and sending the operator chasing the wrong fix.
|
|
287
|
+
function isNoPrError(stderr) {
|
|
288
|
+
return /no (?:open )?pull requests? found/i.test(stderr || '');
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// Resolve the open PR for the CURRENT branch via `gh pr view --json …`. Injectable (`deps`) so a pure
|
|
292
|
+
// node:test can mock gh + git with no network. Returns { number, headRefName, headRefOid }, or fail()s with a
|
|
293
|
+
// clear, actionable message (detached HEAD / no open PR / gh error) — never a stack trace.
|
|
294
|
+
export function resolveCurrentPr({ repo } = {}, deps = {}) {
|
|
295
|
+
const { runGit = git, runGh = ghJson, fail = die } = deps;
|
|
296
|
+
|
|
297
|
+
const branch = runGit(['rev-parse', '--abbrev-ref', 'HEAD']);
|
|
298
|
+
if (!branch || branch === 'HEAD')
|
|
299
|
+
return fail('detached HEAD / not on a branch — checkout the PR branch or pass <PR#> explicitly.');
|
|
300
|
+
|
|
301
|
+
const args = ['pr', 'view', '--json', 'number,state,headRefName,headRefOid'];
|
|
302
|
+
if (repo) args.push('--repo', repo);
|
|
303
|
+
const res = runGh(args);
|
|
304
|
+
if (!res.ok) {
|
|
305
|
+
if (isNoPrError(res.stderr))
|
|
306
|
+
return fail(`no open PR for branch \`${branch}\` — push/open one or pass <PR#>.`);
|
|
307
|
+
return fail(`gh pr view failed for branch \`${branch}\`: ${lastLine(res.stderr)}`);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
let parsed;
|
|
311
|
+
try {
|
|
312
|
+
parsed = JSON.parse(res.stdout);
|
|
313
|
+
} catch {
|
|
314
|
+
return fail(`could not parse \`gh pr view\` output for branch \`${branch}\`.`);
|
|
315
|
+
}
|
|
316
|
+
if (!parsed || typeof parsed.number !== 'number')
|
|
317
|
+
return fail(`\`gh pr view\` returned no PR number for branch \`${branch}\`.`);
|
|
318
|
+
// gh resolves a MERGED/CLOSED PR too (e.g. a reused branch name) — only an OPEN PR is a valid review target.
|
|
319
|
+
if (parsed.state && parsed.state !== 'OPEN')
|
|
320
|
+
return fail(
|
|
321
|
+
`no open PR for branch \`${branch}\` (found #${parsed.number}, state ${parsed.state}) — ` +
|
|
322
|
+
`push/open one or pass <PR#>.`
|
|
323
|
+
);
|
|
324
|
+
return { number: parsed.number, headRefName: parsed.headRefName, headRefOid: parsed.headRefOid };
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// The local HEAD SHA (`git rev-parse HEAD`), or null if it can't be read. Injectable for tests.
|
|
328
|
+
export function currentHeadSha(deps = {}) {
|
|
329
|
+
const { runGit = git } = deps;
|
|
330
|
+
return runGit(['rev-parse', 'HEAD']);
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// FAIL LOUD on an unknown/mismatched agy version. The print contract enforced by runAntigravity (the --model
|
|
334
|
+
// requirement + the argv framing) is version-specific, so a silent warn is what let 1.0.10 ship empty reviews
|
|
335
|
+
// for weeks. A match is silent; an unparseable or mismatched version die()s with a fix-naming message. Deps
|
|
336
|
+
// are injectable (spawn/fail/pinned) so a node:test can exercise both outcomes without a real agy or exiting.
|
|
337
|
+
// NOTE: cross-review.mjs calls this only on the explicit `--agent antigravity` path; the codex→agy fallback
|
|
338
|
+
// runs agy directly (kept intact) — the runAntigravity --model fix already restores its output regardless.
|
|
339
|
+
export function checkAgyVersion(deps = {}) {
|
|
340
|
+
const { spawn = spawnSync, fail: failFn = die, pinned = AGY_PINNED } = deps;
|
|
341
|
+
const r = spawn('agy', ['--version'], { encoding: 'utf8' });
|
|
342
|
+
const m = ((r.stdout || '') + (r.stderr || '')).trim().match(/\d+\.\d+\.\d+/);
|
|
343
|
+
if (!m)
|
|
344
|
+
return failFn(`could not determine agy version (expected ${pinned}) — is the Antigravity CLI installed?`);
|
|
345
|
+
if (m[0] !== pinned)
|
|
346
|
+
return failFn(
|
|
347
|
+
`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 ` +
|
|
349
|
+
`contract and bumps the pin only on a green probe), then commit the bump. ` +
|
|
350
|
+
`Manual path: re-verify runAntigravity() against \`agy --help\`, then bump AGY_PINNED to ${m[0]}.`
|
|
351
|
+
);
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
// Load a shared `*.prompt.md`. The doc opens with an HTML-comment header; the prompt is everything below
|
|
355
|
+
// the first `---` line. Returns the trimmed body, or dies with a clear message.
|
|
356
|
+
export function loadPromptBody(path) {
|
|
357
|
+
if (!existsSync(path)) die(`shared prompt missing at ${path}`);
|
|
358
|
+
const raw = readFileSync(path, 'utf8');
|
|
359
|
+
const marker = raw.indexOf('\n---');
|
|
360
|
+
const body = (marker === -1 ? raw : raw.slice(marker + '\n---'.length)).trim();
|
|
361
|
+
if (!body) die(`shared prompt at ${path} is empty.`);
|
|
362
|
+
return body;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// `opts.soft` makes a runner return null (with a stderr warning) instead of die()-ing on failure — used
|
|
366
|
+
// for non-essential passes (e.g. cross-panel's contradiction synthesis) that should degrade, not abort.
|
|
367
|
+
function fail(soft, msg) {
|
|
368
|
+
if (soft) {
|
|
369
|
+
process.stderr.write(`⚠ ${msg}\n`);
|
|
370
|
+
return null;
|
|
371
|
+
}
|
|
372
|
+
die(msg);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
// Last non-empty line of a stderr blob — the human-readable tail used in failure messages.
|
|
376
|
+
function lastLine(stderr) {
|
|
377
|
+
return (stderr || '').trim().split('\n').filter(Boolean).pop() || 'unknown error';
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Low-level codex exec: prompt rides as an argv string, context is piped on stdin (codex appends it as a
|
|
381
|
+
// <stdin> block). Returns the raw spawn result — callers decide how to interpret status/stdout/stderr.
|
|
382
|
+
function execCodex(prompt, stdin) {
|
|
383
|
+
return spawnSync('codex', ['exec', prompt], {
|
|
384
|
+
input: stdin,
|
|
385
|
+
encoding: 'utf8',
|
|
386
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// codex exec wrapper preserving the original contract: returns trimmed stdout, or fail()s (die unless soft).
|
|
391
|
+
export function runCodex(prompt, stdin, opts = {}) {
|
|
392
|
+
const r = execCodex(prompt, stdin);
|
|
393
|
+
if (r.status !== 0) return fail(opts.soft, `codex exec failed: ${lastLine(r.stderr)}`);
|
|
394
|
+
return (r.stdout || '').trim();
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// Soft, STRUCTURED codex run — never dies. Exposes stderr so the caller can tell an auth failure (token
|
|
398
|
+
// lapsed → fall back) from a non-auth failure (real break → surface it). This is the "degrade, don't die"
|
|
399
|
+
// soft mode made structured; runCodex stays the string-returning variant for its existing direct callers.
|
|
400
|
+
export function tryCodex(prompt, stdin) {
|
|
401
|
+
const r = execCodex(prompt, stdin);
|
|
402
|
+
const stdout = r.stdout || '';
|
|
403
|
+
const stderr = r.stderr || '';
|
|
404
|
+
return {
|
|
405
|
+
ok: r.status === 0,
|
|
406
|
+
text: stdout.trim(),
|
|
407
|
+
authFailed: r.status !== 0 && isCodexAuthError(stderr),
|
|
408
|
+
// See stripGeneratedFileDiffs' header comment: checked on both streams because codex's own trailing
|
|
409
|
+
// diagnostics can land on either, depending on the exact failure path.
|
|
410
|
+
contextOverflow: r.status !== 0 && (isContextWindowOverflow(stdout) || isContextWindowOverflow(stderr)),
|
|
411
|
+
stderr,
|
|
412
|
+
};
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
// True when codex's stderr carries an AUTHENTICATION failure (lapsed/revoked/expired token) — the only
|
|
416
|
+
// signal that should trigger the Antigravity fallback. Confirmed against a live revoked token (2026-06-21):
|
|
417
|
+
// "Failed to refresh token: 401 Unauthorized … refresh_token_invalidated", "Your session has ended. Please
|
|
418
|
+
// log in again.", "your refresh token was revoked. Please log out and sign in again." Kept tight to auth so
|
|
419
|
+
// a genuine non-auth error (empty diff, internal break) falls through and fails clearly instead.
|
|
420
|
+
export function isCodexAuthError(stderr) {
|
|
421
|
+
return /refresh[_ ]?token|session has ended|log ?in again|sign in again|401 unauthorized|token (?:was|is|could not be) (?:revoked|refreshed|expired|invalid)|not authenticated|unauthorized/i.test(
|
|
422
|
+
stderr || ''
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
// Pure fallback decision — the unit under test. Given the outcome of a codex attempt and whether agy is
|
|
427
|
+
// available, return the action to take. No I/O, no exit.
|
|
428
|
+
export function decideCodexFallback({ codexOk, authFailed, contextOverflow, agyAvailable }) {
|
|
429
|
+
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.
|
|
433
|
+
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
|
|
435
|
+
if (!agyAvailable) return 'fail-both-dead';
|
|
436
|
+
return 'fallback';
|
|
437
|
+
}
|
|
438
|
+
|
|
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
|
+
) {
|
|
445
|
+
const {
|
|
446
|
+
tryCodex: tryCodexFn = tryCodex,
|
|
447
|
+
runAntigravity: runAntigravityFn = runAntigravity,
|
|
448
|
+
hasCmd: hasCmdFn = hasCmd,
|
|
449
|
+
fail: failFn = die,
|
|
450
|
+
warn = (m) => process.stderr.write(`${m}\n`),
|
|
451
|
+
} = deps;
|
|
452
|
+
|
|
453
|
+
const codex = tryCodexFn(prompt, stdin);
|
|
454
|
+
const action = decideCodexFallback({
|
|
455
|
+
codexOk: codex.ok,
|
|
456
|
+
authFailed: codex.authFailed,
|
|
457
|
+
contextOverflow: codex.contextOverflow,
|
|
458
|
+
agyAvailable: hasCmdFn('agy'),
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
switch (action) {
|
|
462
|
+
case 'use-codex':
|
|
463
|
+
return { findings: codex.text, fellBack: false };
|
|
464
|
+
case 'fail-context-overflow':
|
|
465
|
+
return failFn(
|
|
466
|
+
"codex exec failed: the diff is too large for Codex's context window. " +
|
|
467
|
+
'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 ' +
|
|
469
|
+
'seeing this, either that allowlist needs a new pattern for this file, or the PR has a genuinely ' +
|
|
470
|
+
'large hand-written diff that needs splitting.'
|
|
471
|
+
);
|
|
472
|
+
case 'fail-non-auth':
|
|
473
|
+
return failFn(`codex exec failed (non-auth): ${lastLine(codex.stderr)}`);
|
|
474
|
+
case 'fail-both-dead':
|
|
475
|
+
return failFn(
|
|
476
|
+
'Codex token revoked AND Antigravity unavailable — restore Codex with `codex login`, ' +
|
|
477
|
+
'or install + authenticate the Antigravity CLI (agy).'
|
|
478
|
+
);
|
|
479
|
+
case 'fallback':
|
|
480
|
+
default:
|
|
481
|
+
warn('⚠ Codex unavailable (token revoked) → falling back to Antigravity. Restore: `codex login`.');
|
|
482
|
+
return {
|
|
483
|
+
findings: runAntigravityFn(antigravityArgv),
|
|
484
|
+
fellBack: true,
|
|
485
|
+
from: 'codex',
|
|
486
|
+
to: 'antigravity',
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// One `agy -p "<prompt>" --model "<MODEL>"` invocation. The prompt+framed context ride in `fullArgv` (stdin is
|
|
492
|
+
// NOT the prompt and must be at EOF — input:'' gives an immediate EOF or print mode blocks forever). Returns
|
|
493
|
+
// the raw spawn result; the caller classifies status/stdout.
|
|
494
|
+
function execAgy(fullArgv, model, spawn) {
|
|
495
|
+
return spawn('agy', ['-p', fullArgv, '--model', model], {
|
|
496
|
+
input: '',
|
|
497
|
+
encoding: 'utf8',
|
|
498
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
// agy 1.0.10 print mode: --model is REQUIRED (or `--print` exits 0 with NO output), and a quota-exhausted /
|
|
503
|
+
// unreachable model ALSO exits 0 with empty stdout (the 429 lands only in agy's log). So an empty result is a
|
|
504
|
+
// real failure, not success — we try AGY_MODEL first and, on empty, retry once with AGY_FALLBACK_MODEL (a
|
|
505
|
+
// separate quota pool) so a Gemini quota exhaustion degrades to GPT-OSS instead of silently blanking the review.
|
|
506
|
+
// We enforce the argv size cap up front (clear message, not an opaque E2BIG). `deps.spawn` is injectable for tests.
|
|
507
|
+
export function runAntigravity(fullArgv, opts = {}, deps = {}) {
|
|
508
|
+
const { spawn = spawnSync, warn = (m) => process.stderr.write(`${m}\n`) } = deps;
|
|
509
|
+
if (Buffer.byteLength(fullArgv, 'utf8') > AGY_ARG_LIMIT) {
|
|
510
|
+
return fail(
|
|
511
|
+
opts.soft,
|
|
512
|
+
`input too large for antigravity (${Math.round(Buffer.byteLength(fullArgv) / 1024)} KB > ` +
|
|
513
|
+
`${AGY_ARG_LIMIT / 1024} KB; agy takes the prompt in argv, not stdin) — use --agent codex instead.`
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// opts.models lets a caller pick its own primary→fallback pair (e.g. prose-draft
|
|
518
|
+
// runs a cheaper Flash model) without forking the retry/empty-output plumbing.
|
|
519
|
+
// Default: the review pair above — existing callers are byte-identical in behavior.
|
|
520
|
+
const modelPair = opts.models?.length
|
|
521
|
+
? [...new Set(opts.models)]
|
|
522
|
+
: AGY_MODEL === AGY_FALLBACK_MODEL ? [AGY_MODEL] : [AGY_MODEL, AGY_FALLBACK_MODEL];
|
|
523
|
+
const tried = [];
|
|
524
|
+
for (const model of modelPair) {
|
|
525
|
+
const r = execAgy(fullArgv, model, spawn);
|
|
526
|
+
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
|
+
const last = (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'unknown error';
|
|
530
|
+
return fail(opts.soft, `agy -p failed (model "${model}"): ${last}`);
|
|
531
|
+
}
|
|
532
|
+
const out = (r.stdout || '').trim();
|
|
533
|
+
if (out) {
|
|
534
|
+
if (model !== modelPair[0]) warn(`⚠ agy "${modelPair[0]}" returned no output (quota/unavailable?) → used "${model}".`);
|
|
535
|
+
return out;
|
|
536
|
+
}
|
|
537
|
+
tried.push(model);
|
|
538
|
+
}
|
|
539
|
+
return fail(
|
|
540
|
+
opts.soft,
|
|
541
|
+
`agy returned no output for ${tried.map((m) => `"${m}"`).join(' and ')} — likely a quota cap ` +
|
|
542
|
+
`("RESOURCE_EXHAUSTED 429") or an unavailable model. Set AGY_MODEL / AGY_FALLBACK_MODEL to a model ` +
|
|
543
|
+
`\`agy models\` lists with remaining quota, or use --agent codex.`
|
|
544
|
+
);
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
// One `vibe --prompt "<prompt+context>" --agent plan --output text` invocation. Like agy, vibe takes the
|
|
548
|
+
// 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`.
|
|
552
|
+
//
|
|
553
|
+
// Empty stdout is treated as a FAILURE, not as "no findings". Every CLI on this roster can exit 0 having
|
|
554
|
+
// produced nothing when it is quota-capped or misconfigured, and a review that silently becomes empty is
|
|
555
|
+
// worse than one that errors — it reads as a clean pass. `deps.spawn` is injectable for tests.
|
|
556
|
+
export function runVibe(fullArgv, opts = {}, deps = {}) {
|
|
557
|
+
const { spawn = spawnSync } = deps;
|
|
558
|
+
if (Buffer.byteLength(fullArgv, 'utf8') > VIBE_ARG_LIMIT) {
|
|
559
|
+
return fail(
|
|
560
|
+
opts.soft,
|
|
561
|
+
`input too large for vibe (${Math.round(Buffer.byteLength(fullArgv) / 1024)} KB > ` +
|
|
562
|
+
`${VIBE_ARG_LIMIT / 1024} KB; vibe takes the prompt in argv, not stdin) — use --agent codex instead.`
|
|
563
|
+
);
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
const args = [
|
|
567
|
+
'--prompt',
|
|
568
|
+
fullArgv,
|
|
569
|
+
'--agent',
|
|
570
|
+
'plan',
|
|
571
|
+
'--output',
|
|
572
|
+
'text',
|
|
573
|
+
'--max-turns',
|
|
574
|
+
String(VIBE_MAX_TURNS),
|
|
575
|
+
'--trust',
|
|
576
|
+
'--disabled-tools',
|
|
577
|
+
'*',
|
|
578
|
+
];
|
|
579
|
+
if (VIBE_MODEL) args.push('--model', VIBE_MODEL);
|
|
580
|
+
|
|
581
|
+
const r = spawn('vibe', args, { input: '', encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
582
|
+
if (r.error)
|
|
583
|
+
return fail(
|
|
584
|
+
opts.soft,
|
|
585
|
+
`vibe not found or failed to spawn (${r.error.message}) — install the Mistral Vibe CLI ` +
|
|
586
|
+
`(\`uv tool install mistral-vibe\`) and authenticate it, or use --agent codex/antigravity.`
|
|
587
|
+
);
|
|
588
|
+
if (r.status !== 0) {
|
|
589
|
+
const detail = lastLine(r.stderr) || lastLine(r.stdout);
|
|
590
|
+
const hint = /turn limit/i.test(`${r.stderr || ''}${r.stdout || ''}`)
|
|
591
|
+
? ` — the agent ran out of turns, not quota. Raise VIBE_MAX_TURNS (currently ${VIBE_MAX_TURNS}) and re-run.`
|
|
592
|
+
: '';
|
|
593
|
+
return fail(opts.soft, `vibe --prompt failed: ${detail}${hint}`);
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const out = (r.stdout || '').trim();
|
|
597
|
+
if (!out)
|
|
598
|
+
return fail(
|
|
599
|
+
opts.soft,
|
|
600
|
+
`vibe returned no output — likely a quota cap, an auth lapse, or an untrusted folder. Verify with ` +
|
|
601
|
+
`\`vibe --prompt "say OK" --output text --trust\`; if that is empty too, re-authenticate. ` +
|
|
602
|
+
`(An empty result is a failure, never "no findings" — see runVibe's header.)`
|
|
603
|
+
);
|
|
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)) {
|
|
608
|
+
return fail(
|
|
609
|
+
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.'
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
return out;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
// One `claude -p "<prompt>"` invocation with the context piped on stdin (same shape as codex, which is why
|
|
617
|
+
// cross-review can hand both runners the identical prompt/stdin pair). Tool-less and MCP-less by
|
|
618
|
+
// construction — see CLAUDE_REVIEW_MODEL's header for why each flag is there.
|
|
619
|
+
//
|
|
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.
|
|
624
|
+
export function runClaudeCode(prompt, stdin, opts = {}, deps = {}) {
|
|
625
|
+
const { spawn = spawnSync } = deps;
|
|
626
|
+
const args = [
|
|
627
|
+
'-p', prompt,
|
|
628
|
+
'--model', CLAUDE_REVIEW_MODEL,
|
|
629
|
+
'--tools', '',
|
|
630
|
+
'--strict-mcp-config',
|
|
631
|
+
'--output-format', 'text',
|
|
632
|
+
'--no-session-persistence',
|
|
633
|
+
'--bare',
|
|
634
|
+
];
|
|
635
|
+
|
|
636
|
+
const r = spawn('claude', args, { input: stdin, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
637
|
+
if (r.error)
|
|
638
|
+
return fail(
|
|
639
|
+
opts.soft,
|
|
640
|
+
`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.`
|
|
642
|
+
);
|
|
643
|
+
if (r.status !== 0) return fail(opts.soft, `claude -p failed: ${lastLine(r.stderr)}`);
|
|
644
|
+
|
|
645
|
+
const out = (r.stdout || '').trim();
|
|
646
|
+
if (!out)
|
|
647
|
+
return fail(
|
|
648
|
+
opts.soft,
|
|
649
|
+
`claude returned no output — likely a usage cap or an expired session. Check \`claude auth status\`, ` +
|
|
650
|
+
`or use --agent codex/antigravity/vibe. (An empty result is a failure, never "no findings".)`
|
|
651
|
+
);
|
|
652
|
+
return out;
|
|
653
|
+
}
|
|
654
|
+
|
|
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.
|
|
659
|
+
// `devin -p --prompt-file <file>` runs non-interactively and prints the response. The prompt (framing +
|
|
660
|
+
// diff) rides in a FILE, not argv, so there is NO size cap to guard — the whole reason to prefer prompt-file
|
|
661
|
+
// over agy's argv path for large diffs. Empty stdout is a failure (a quota-capped devin, like agy, exits 0
|
|
662
|
+
// with nothing). Uses the account default model. `deps` is injectable so a node:test drives it without a
|
|
663
|
+
// real devin binary or touching the real filesystem.
|
|
664
|
+
export function runDevin(prompt, opts = {}, deps = {}) {
|
|
665
|
+
const {
|
|
666
|
+
spawn = spawnSync,
|
|
667
|
+
writeFile = writeFileSync,
|
|
668
|
+
mkdtemp = mkdtempSync,
|
|
669
|
+
rm = rmSync,
|
|
670
|
+
} = deps;
|
|
671
|
+
let dir;
|
|
672
|
+
try {
|
|
673
|
+
dir = mkdtemp(join(tmpdir(), 'xrev-devin-'));
|
|
674
|
+
} catch (e) {
|
|
675
|
+
return fail(opts.soft, `could not create a temp dir for devin's prompt file: ${e.message}`);
|
|
676
|
+
}
|
|
677
|
+
const file = join(dir, 'prompt.md');
|
|
678
|
+
try {
|
|
679
|
+
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.`);
|
|
682
|
+
if (r.status !== 0) {
|
|
683
|
+
const last = (r.stderr || '').trim().split('\n').filter(Boolean).pop() || 'unknown error';
|
|
684
|
+
return fail(opts.soft, `devin -p failed: ${last}`);
|
|
685
|
+
}
|
|
686
|
+
const out = (r.stdout || '').trim();
|
|
687
|
+
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.`);
|
|
689
|
+
}
|
|
690
|
+
return out;
|
|
691
|
+
} finally {
|
|
692
|
+
// Best-effort cleanup — a leaked temp prompt file must never fail the review.
|
|
693
|
+
try { rm(dir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
694
|
+
}
|
|
695
|
+
}
|