@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.
Files changed (58) hide show
  1. package/README.md +25 -1
  2. package/bin.mjs +108 -0
  3. package/dist/LICENSE +202 -0
  4. package/dist/NOTICE +7 -0
  5. package/dist/babysit-pr.mjs +213 -0
  6. package/dist/build-order-sync.mjs +141 -0
  7. package/dist/build-order.mjs +178 -0
  8. package/dist/cross-panel.mjs +216 -0
  9. package/dist/cross-panel.prompt.md +115 -0
  10. package/dist/doc-format.enforced.json +4 -0
  11. package/dist/doc-format.mjs +856 -0
  12. package/dist/doc-hygiene.mjs +279 -0
  13. package/dist/init.mjs +132 -0
  14. package/dist/lib/cross-agent-cli.mjs +695 -0
  15. package/dist/lib/gh-rest.mjs +229 -0
  16. package/dist/lib/golden-onboarding.mjs +204 -0
  17. package/dist/lib/jev.mjs +334 -0
  18. package/dist/lib/log-branch.mjs +106 -0
  19. package/dist/lib/pmo-benchmarks.mjs +61 -0
  20. package/dist/lib/pmo-delivery.mjs +102 -0
  21. package/dist/lib/pmo-metrics.mjs +200 -0
  22. package/dist/lib/pmo-templates.mjs +132 -0
  23. package/dist/lib/pmo-window-log.mjs +99 -0
  24. package/dist/lib/project-root.mjs +78 -0
  25. package/dist/lib/prose-brief.mjs +191 -0
  26. package/dist/lib/prose-guard.mjs +761 -0
  27. package/dist/lib/prose-writer.mjs +410 -0
  28. package/dist/lib/report-registry.mjs +469 -0
  29. package/dist/lib/reporting-config.mjs +266 -0
  30. package/dist/lib/roadmap-contract.mjs +305 -0
  31. package/dist/lib/roadmap-status-buckets.mjs +20 -0
  32. package/dist/lib/standup-deck.mjs +120 -0
  33. package/dist/lib/telegram-format.mjs +91 -0
  34. package/dist/live-smoke.mjs +303 -0
  35. package/dist/pmo/benchmarks.json +77 -0
  36. package/dist/pmo/templates/metrics-sheet.md +27 -0
  37. package/dist/pmo/templates/monthly-stakeholder-packet.md +53 -0
  38. package/dist/pmo/templates/weekly-story-deck.md +66 -0
  39. package/dist/pmo-report.mjs +338 -0
  40. package/dist/preflight.mjs +444 -0
  41. package/dist/prose/cpo-persona.md +108 -0
  42. package/dist/prose/internal.task.md +48 -0
  43. package/dist/prose/standup.task.md +38 -0
  44. package/dist/prose/weekly.task.md +36 -0
  45. package/dist/prose-draft.mjs +198 -0
  46. package/dist/prose-lessons.md +59 -0
  47. package/dist/roadmap-extract.mjs +410 -0
  48. package/dist/skeleton/Roadmap/00-ideas/README.md +85 -0
  49. package/dist/skeleton/Roadmap/00-ideas/audits/.gitkeep +0 -0
  50. package/dist/skeleton/Roadmap/00-ideas/seeds/.gitkeep +0 -0
  51. package/dist/skeleton/Roadmap/LEARNINGS.md +164 -0
  52. package/dist/skeleton/Roadmap/README.md +67 -0
  53. package/dist/skeleton/Roadmap/WAYS-OF-WORKING.md +233 -0
  54. package/dist/standup/templates/daily-story-deck.md +46 -0
  55. package/dist/standup.mjs +634 -0
  56. package/dist/vercel-prune-previews.mjs +164 -0
  57. package/dist/weekly-recap.mjs +526 -0
  58. 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
+ }