@golden-frijoles/kit 0.5.4 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,857 @@
1
+ #!/usr/bin/env node
2
+ // cross-review.mjs — the cross-agent judgment-layer review for a pull-request diff.
3
+ //
4
+ // Pipes `gh pr diff <PR#>` into the external Antigravity reviewer with the shared prompt
5
+ // (scripts/cross-review.prompt.md = the five AGENTS rules + WAYS single-pass discipline) and prints the
6
+ // findings. It is dev tooling, not app code, and it is deliberately:
7
+ // • SINGLE-PASS — one read, no debate / iterate-to-convergence loop (our #1 token sink, out of scope).
8
+ // • THE JUDGMENT-LAYER REVIEW (updated 2026-07-23, WAYS-OF-WORKING "Review & merge — cross-agent") —
9
+ // running this with --agent antigravity replaces spawning an internal same-family reviewer for
10
+ // ordinary PRs. High-risk work adds an independent Devin pass outside this script. A Blocking finding must
11
+ // be resolved (fixed, or explicitly triaged) before merge — this is no longer background-only noise.
12
+ // It still isn't a second CI: CI decides green/red mechanically, this decides whether the diff holds
13
+ // up, and the risk-tier rule still decides *who* clicks merge.
14
+ //
15
+ // Usage:
16
+ // node scripts/cross-review.mjs [PR#] --agent codex --builder <family> [--repo owner/repo] [--force] [--dry-run]
17
+ // [--skip-trivial] [--min-lines N]
18
+ //
19
+ // --skip-trivial is the CI cost guard: skip (exit 0, no comment) when the PR is docs-only or under
20
+ // --min-lines (default 10) changed lines, so "every PR" doesn't pay for a review on a typo. Off by default
21
+ // so the manual command always reviews.
22
+ //
23
+ // <PR#> is OPTIONAL: with none, the command resolves the open PR for the CURRENT branch (so the FIRST run
24
+ // reviews the right diff, no rerun) and refuses a stale local HEAD unless --force. An explicit <PR#> still
25
+ // overrides (and bypasses the stale guard — the deliberate escape hatch).
26
+ //
27
+ // Default posts the findings as a labeled PR comment; --dry-run prints instead.
28
+ // `gh` resolves the repo from the current directory; pass --repo to target another (e.g. the app repo).
29
+ // Zero npm deps — Node 18+. CLI plumbing is shared with cross-panel.mjs via scripts/lib/cross-agent-cli.mjs.
30
+ //
31
+ // The diff is passed through stripGeneratedFileDiffs() before it reaches the reviewer CLI — a large
32
+ // auto-generated file (a committed package-lock.json, ~12–19K lines) blew Codex's context window live
33
+ // (deploy-pipeline-tuning epic, 2026-07-11); the reviewer still sees THAT the file changed, just not its
34
+ // (huge, low-signal) content. See that function's header comment in cross-agent-cli.mjs for the full story.
35
+ // --include-lockfiles opts back into the raw, unstripped diff for the rare case of reviewing a hand-edited
36
+ // lockfile itself.
37
+
38
+ import { spawnSync } from 'node:child_process';
39
+ import { readFileSync, realpathSync } from 'node:fs';
40
+ import { homedir } from 'node:os';
41
+ import { fileURLToPath } from 'node:url';
42
+ import { dirname, join } from 'node:path';
43
+ import { readSection } from './lib/config.mjs';
44
+ import { projectRoot } from './lib/project-root.mjs';
45
+ import { collectSecretValues, decideAuthorTrust, findSecretLeaks } from './lib/secret-guard.mjs';
46
+ import {
47
+ AGENTS,
48
+ headSidePaths,
49
+ die,
50
+ need,
51
+ ensureCmd,
52
+ ensureGh,
53
+ checkAgyVersion,
54
+ loadPromptBody,
55
+ runAntigravity,
56
+ runWithCodexFallback,
57
+ runVibe,
58
+ AGY_MODEL,
59
+ codexModelFrom,
60
+ CODEX_REASONING_EFFORT,
61
+ CLAUDE_REVIEW_MODEL,
62
+ runClaudeCode,
63
+ AGENT_BIN,
64
+ resolveCurrentPr,
65
+ currentHeadSha,
66
+ decideHeadGuard,
67
+ decideTrivialSkip,
68
+ stripGeneratedFileDiffs,
69
+ stripDocFileDiffs,
70
+ buildFileContext,
71
+ renderFileContext,
72
+ filterDiffToPaths,
73
+ AGY_ARG_LIMIT,
74
+ VIBE_ARG_LIMIT,
75
+ shortSha,
76
+ checkReviewerPairing,
77
+ reviewersFor,
78
+ BUILDER_FAMILIES,
79
+ } from './lib/cross-agent-cli.mjs';
80
+ import {
81
+ changedFileCount,
82
+ cliVersionNote,
83
+ decideSecurityPass,
84
+ parseReviewConfig,
85
+ isReReview,
86
+ postReviewStatus,
87
+ reviewMarker,
88
+ RE_REVIEW_NOTE,
89
+ jevMarker,
90
+ judgeReviewOutput,
91
+ } from './lib/review-guard.mjs';
92
+ import { jevContext } from './lib/jev.mjs';
93
+
94
+ const __dirname = dirname(fileURLToPath(import.meta.url));
95
+ const REVIEW_CONFIG_PATH = join(__dirname, 'review-config.json');
96
+ const PROMPT_PATH = join(__dirname, 'cross-review.prompt.md');
97
+ // `--lens security` swaps the prompt ONLY ��� agent selection, the fallback chain, --skip-trivial and the
98
+ // output guard are untouched, so the lens cannot regress the rail that reviews every other PR.
99
+ const SECURITY_PROMPT_PATH = join(__dirname, 'cross-review.security.prompt.md');
100
+ export const LENSES = ['security'];
101
+
102
+ // THROWS rather than die()s: a pure function that exits cannot be unit-tested, and silently falling back
103
+ // to the general prompt would let an operator believe a security pass ran when a general one did.
104
+ export function promptPathFor(lens) {
105
+ if (!lens) return PROMPT_PATH;
106
+ if (!LENSES.includes(lens)) throw new Error(`unknown lens '${lens}' (expected: ${LENSES.join(' | ')})`);
107
+ return SECURITY_PROMPT_PATH;
108
+ }
109
+
110
+ const BANNER =
111
+ '> **Cross-agent review — every finding is fixed, or answered on this PR, before merge. This does not authorize one.** ' +
112
+ 'CI and the risk-tier merge rule remain the only merge authority. A fresh `pr-reviewer` pass covers context independence; ' +
113
+ 'this is the family-independence pass: one single-pass read by a model family that did not build the diff.';
114
+
115
+ const HELP = `cross-review.mjs — the cross-agent judgment-layer review for a PR diff.
116
+
117
+ Usage:
118
+ node scripts/cross-review.mjs [PR#] --agent codex --builder <family> [--repo owner/repo] [--force] [--dry-run]
119
+
120
+ [PR#] is optional — omit it to review the open PR for the CURRENT branch.
121
+
122
+ Flags:
123
+ --agent <name> reviewer CLI: ${Object.keys(AGENTS).join(' | ')} (default: codex)
124
+ --paths a,b,c review ONLY files whose path contains one of these. The reduced
125
+ scope is STATED in the posted comment.
126
+ --code-only drop doc/markdown hunks so a big diff fits agy's 256 KB argv cap.
127
+ The reduced scope is STATED in the posted comment.
128
+ --allow-untrusted-author review a PR whose author lacks write access (read the diff first).
129
+ --builder <family> who WROTE this diff: ${BUILDER_FAMILIES.join(' | ')}. Refuses a
130
+ same-family review (a family cannot clear its own work).
131
+ --repo owner/repo target a specific repo (default: the repo of the current directory)
132
+ --force proceed even when local HEAD differs from the resolved PR head (auto-resolve only)
133
+ --skip-trivial skip (exit 0, no comment) when the PR is docs-only or under --min-lines changed lines
134
+ --min-lines N trivial-diff threshold for --skip-trivial (default: 10)
135
+ --include-lockfiles send the RAW diff, including generated files (package-lock.json, yarn.lock, etc.) —
136
+ normally stripped to a placeholder to avoid blowing the reviewer's context window
137
+ --dry-run print the comment instead of posting it (alias: --no-comment)
138
+ -h, --help show this help
139
+
140
+ With no [PR#], resolves the branch's PR via \`gh pr view\` and refuses a stale local HEAD unless --force.
141
+ An explicit [PR#] overrides resolution and bypasses the stale guard.
142
+
143
+ The judgment-layer review — not a second CI. CI (green/red) + the risk-tier rule (who merges) decide the rest.`;
144
+
145
+ function parseArgs(argv) {
146
+ const out = {
147
+ pr: null,
148
+ agent: 'codex',
149
+ builder: process.env.CROSS_REVIEW_BUILDER || '',
150
+ codeOnly: false,
151
+ allowUntrustedAuthor: false,
152
+ paths: [],
153
+ repo: null,
154
+ force: false,
155
+ dryRun: false,
156
+ skipTrivial: false,
157
+ minLines: 10,
158
+ lens: null,
159
+ includeLockfiles: false,
160
+ help: false,
161
+ };
162
+ for (let i = 0; i < argv.length; i++) {
163
+ const a = argv[i];
164
+ if (a === '--help' || a === '-h') out.help = true;
165
+ else if (a === '--dry-run' || a === '--no-comment') out.dryRun = true;
166
+ else if (a === '--force') out.force = true;
167
+ else if (a === '--skip-trivial') out.skipTrivial = true;
168
+ else if (a === '--include-lockfiles') out.includeLockfiles = true;
169
+ else if (a === '--min-lines') out.minLines = parseMinLines(need(argv[++i], '--min-lines'));
170
+ else if (a.startsWith('--min-lines=')) out.minLines = parseMinLines(a.slice('--min-lines='.length));
171
+ else if (a === '--code-only') out.codeOnly = true;
172
+ else if (a === '--allow-untrusted-author') out.allowUntrustedAuthor = true;
173
+ else if (a === '--paths')
174
+ out.paths = need(argv[++i], '--paths')
175
+ .split(',')
176
+ .map((x) => x.trim())
177
+ .filter(Boolean);
178
+ else if (a === '--builder') out.builder = need(argv[++i], '--builder');
179
+ else if (a.startsWith('--builder=')) out.builder = a.slice('--builder='.length);
180
+ else if (a === '--lens') out.lens = need(argv[++i], '--lens');
181
+ else if (a.startsWith('--lens=')) out.lens = a.slice('--lens='.length);
182
+ else if (a === '--agent') out.agent = need(argv[++i], '--agent');
183
+ else if (a.startsWith('--agent=')) out.agent = a.slice('--agent='.length);
184
+ else if (a === '--repo') out.repo = need(argv[++i], '--repo');
185
+ else if (a.startsWith('--repo=')) out.repo = a.slice('--repo='.length);
186
+ else if (!a.startsWith('-') && out.pr === null) out.pr = a;
187
+ else die(`unknown argument '${a}' (try --help)`);
188
+ }
189
+ return out;
190
+ }
191
+
192
+ function parseMinLines(v) {
193
+ const n = Number(v);
194
+ if (!Number.isInteger(n) || n < 0) die(`--min-lines must be a non-negative integer, got '${v}'.`);
195
+ return n;
196
+ }
197
+
198
+ function ghDiff(pr, repo) {
199
+ const args = ['pr', 'diff', String(pr)];
200
+ if (repo) args.push('--repo', repo);
201
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
202
+ if (r.status !== 0) {
203
+ const first = (r.stderr || '').trim().split('\n')[0] || 'unknown error';
204
+ die(`gh pr diff failed for #${pr}: ${first}`);
205
+ }
206
+ if (!r.stdout || !r.stdout.trim()) die(`PR #${pr} has an empty diff (wrong number or repo?).`);
207
+ return r.stdout;
208
+ }
209
+
210
+ // Changed-file stats for the cost guard: [{ path, additions, deletions }, …]. Returns [] on any failure so
211
+ // the guard degrades to "not trivial" (review runs) rather than silently skipping on a transient gh hiccup.
212
+ function ghFiles(pr, repo) {
213
+ const args = ['pr', 'view', String(pr), '--json', 'files'];
214
+ if (repo) args.push('--repo', repo);
215
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
216
+ if (r.status !== 0) return [];
217
+ try {
218
+ return JSON.parse(r.stdout || '{}').files || [];
219
+ } catch {
220
+ return [];
221
+ }
222
+ }
223
+
224
+ /**
225
+ * The PR's head commit SHA, or null when it cannot be resolved.
226
+ *
227
+ * Deliberately tolerant about RESOLVING: a `gh` hiccup returns null rather than aborting a review.
228
+ * It is NOT tolerant about substituting — when the head cannot be resolved, the caller omits the
229
+ * file context instead of reading a different version of it from the working tree. Getting that
230
+ * backwards is what produced a confidently-wrong Blocking finding on PR #118.
231
+ */
232
+ function resolvePrHeadOid(pr, repo) {
233
+ const args = ['pr', 'view', String(pr), '--json', 'headRefOid', '-q', '.headRefOid'];
234
+ if (repo) args.push('--repo', repo);
235
+ const out = spawnSync('gh', args, { encoding: 'utf8' });
236
+ if (out.status !== 0) return null;
237
+ const oid = String(out.stdout || '').trim();
238
+ return /^[0-9a-f]{40}$/.test(oid) ? oid : null;
239
+ }
240
+
241
+ // agy 1.0.7 has no stdin, so the diff rides embedded in the argv string.
242
+ function agyArgv(prompt, diff) {
243
+ return `${prompt}\n\n## PR diff to review\n\n\`\`\`diff\n${diff}\n\`\`\`\n`;
244
+ }
245
+
246
+ // ── This dispatches on `agent`, and did not until 2026-07-26 ─────────────────────────────────
247
+ // It used to be `return runAntigravity(...)` unconditionally, which was harmless only while the
248
+ // argument guard above rejected every value except 'antigravity'. The moment Codex was re-enabled
249
+ // as a reviewer, `--agent codex` produced an AGY review posted under a comment header reading
250
+ // "Cross-agent review (Codex)" — the label came from AGENTS[agent] while the text came from
251
+ // whatever runReview felt like running.
252
+ //
253
+ // That is precisely the failure Roadmap/LEARNINGS.md records as "an audit label that can be chosen
254
+ // by picking an endpoint is worse than no audit log", and it is worse here than in the original
255
+ // incident: the entire value of this rail is model-FAMILY contrast, so a mislabeled review doesn't
256
+ // just misfile a record, it silently reports two independent reads where only one happened. A
257
+ // reviewer trusting the label would conclude the other family had cleared the diff.
258
+ //
259
+ // Each CLI takes its context differently — codex and claude on STDIN, agy and vibe as one argv string.
260
+ // See lib/cross-agent-cli.mjs; keeping that difference in one place is why the wrappers live there.
261
+ //
262
+ // Note the explicit `die` on an unknown agent. This used to end in a bare `return runAntigravity(...)`,
263
+ // i.e. "anything that isn't codex is agy". That was harmless while the roster was two families, and
264
+ // became a real bug the moment it grew: `--agent vibe` would have run AGY and posted the findings under
265
+ // a Mistral label. A cross-family review that names the wrong family is worse than none — a later
266
+ // reviewer trusting the label would conclude a family had cleared the diff when it never read it.
267
+ // Returns { findings, fellBack }. codex self-heals to agy on a lapsed token or a stale CLI
268
+ // (runWithCodexFallback), and `builder` is re-checked against that fallback family so the heal can never
269
+ // turn into a same-family review (distribute-what-we-use D1).
270
+ function runReview(agent, prompt, diff, { builder = '', onModel } = {}) {
271
+ const stdinContext = `## PR diff to review\n\n\`\`\`diff\n${diff}\n\`\`\`\n`;
272
+ if (agent === 'codex')
273
+ return runWithCodexFallback(
274
+ {
275
+ prompt,
276
+ stdin: stdinContext,
277
+ antigravityArgv: agyArgv(prompt, diff),
278
+ builder,
279
+ },
280
+ {
281
+ // The heal is still an agy run, so it gets agy's version-pin check like `--agent antigravity` does
282
+ // (pr-reviewer S3): a drifted print contract would otherwise review silently on the heal path.
283
+ runAntigravity: (argv) => {
284
+ checkAgyVersion();
285
+ return runAntigravity(argv, { onModel });
286
+ },
287
+ }
288
+ );
289
+ if (agent === 'antigravity')
290
+ return { findings: runAntigravity(agyArgv(prompt, diff), { onModel }), fellBack: false };
291
+ if (agent === 'vibe') return { findings: runVibe(agyArgv(prompt, diff)), fellBack: false };
292
+ if (agent === 'claude') return { findings: runClaudeCode(prompt, stdinContext), fellBack: false };
293
+ die(`unknown --agent '${agent}'; use ${Object.keys(AGENTS).join('|')}`);
294
+ }
295
+
296
+ /**
297
+ * The posted comment. Signature `(agentLabel, findings, fellBack, opts)` — the template's and that consumer's; the
298
+ * older `(agentLabel, findings, opts)` shape is still accepted, so no caller breaks (distribute-what-we-use D1).
299
+ *
300
+ * `model` is recorded because the reviewer model is MACHINE-LOCAL state that no artifact used to capture:
301
+ * if it drifts, review strength changes family and nothing notices. When it genuinely cannot be resolved
302
+ * the comment says so, rather than printing a default that may be wrong (a consuming project).
303
+ */
304
+ export function buildComment(agentLabel, findings, fellBack = false, opts = {}) {
305
+ if (fellBack !== null && typeof fellBack === 'object') [fellBack, opts] = [false, fellBack];
306
+ const { lens = null, model, version = null, reReview = false, securityOwed = null } = opts;
307
+ // When codex fell back, make it unmistakable so nobody reads an Antigravity review as a Codex one.
308
+ const header = fellBack ? `${AGENTS.antigravity} — Codex unavailable` : agentLabel;
309
+ const title = lens
310
+ ? `### 🔐 Cross-agent review — ${lens} lens (${header})`
311
+ : `### 🔎 Cross-agent review (${header})`;
312
+ // The reviewer CLI's version and model are recorded, not enforced — see lib/review-guard.mjs. `model`
313
+ // left undefined means the caller did not try; `null` means it tried and could not resolve.
314
+ const parts = [];
315
+ if (model !== undefined) parts.push(`Model: ${model || 'unrecorded — resolve failed'}`);
316
+ if (version) parts.push(version);
317
+ const attribution = parts.length ? `\n\n_${parts.join(' · ')}._` : '';
318
+ const limit =
319
+ lens === 'security'
320
+ ? `\n\n> **Scope of this pass:** one advisory, single-pass read by a different model family, triggered by the changed paths. It is **not** static analysis, not exhaustive, and not a required check. A clean result here is not a security guarantee.`
321
+ : '';
322
+ // A general pass on a security-path PR must not read as the whole review.
323
+ const owed = securityOwed
324
+ ? `\n\n> ⚠ **The security lens is OWED on this PR** (${securityOwed}) and has not run here. This general pass is not a substitute for it.`
325
+ : '';
326
+ const convergence = reReview
327
+ ? `\n\n> **Re-review:** Blocking/Should-fix findings only — earlier nits are deliberately not repeated.`
328
+ : '';
329
+ return `${title}\n\n${BANNER}${attribution}${limit}${owed}${convergence}\n\n---\n\n${findings}\n`;
330
+ }
331
+
332
+ /**
333
+ * The model that answered, for the comment's attribution line. `usedAgyModel` is what runAntigravity
334
+ * actually ran (its onModel callback), which beats the configured pair. Returns null when codex's model
335
+ * cannot be determined — a missing attribution is safer than a false one (a consuming project).
336
+ */
337
+ export function resolveReviewModel(agent, fellBack, deps = {}) {
338
+ const { env = process.env, readCfg = defaultReadCodexConfig, usedAgyModel = null } = deps;
339
+ if (fellBack || agent === 'antigravity') return `agy ${usedAgyModel || env.AGY_MODEL || AGY_MODEL}`;
340
+ if (agent === 'vibe') return env.VIBE_MODEL || env.VIBE_ACTIVE_MODEL || 'vibe configured default';
341
+ if (agent === 'claude') return env.CLAUDE_REVIEW_MODEL || CLAUDE_REVIEW_MODEL;
342
+ // The same resolution execCodex uses: unset → the pin. `default` → codex's BUILT-IN default: the review
343
+ // runs `--ignore-user-config`, so ~/.codex/config.toml is not what ran, and naming its model would be a
344
+ // false attribution (pr-reviewer round 3 on #188). `readCfg` is kept only so old callers' deps still parse.
345
+ void readCfg;
346
+ const pinned = 'codexModel' in deps ? deps.codexModel : codexModelFrom(env.CODEX_MODEL);
347
+ if (pinned) return `${pinned} (effort: ${env.CODEX_REASONING_EFFORT || CODEX_REASONING_EFFORT})`;
348
+ return `codex built-in default (effort: ${env.CODEX_REASONING_EFFORT || CODEX_REASONING_EFFORT}; user config ignored)`;
349
+ }
350
+
351
+ function defaultReadCodexConfig() {
352
+ try {
353
+ return readFileSync(join(homedir(), '.codex', 'config.toml'), 'utf8');
354
+ } catch {
355
+ return null;
356
+ }
357
+ }
358
+
359
+ /** Comment bodies already on the PR, for re-review convergence. [] on any failure (degrade to first pass). */
360
+ /** The PR author's permission on the repo (admin/maintain/write/triage/read/none), or null if unreadable. */
361
+ function prAuthorPermission(pr, repo) {
362
+ const view = spawnSync(
363
+ 'gh',
364
+ ['pr', 'view', String(pr), '--json', 'author', '-q', '.author.login', ...(repo ? ['--repo', repo] : [])],
365
+ { encoding: 'utf8' }
366
+ );
367
+ const login = String(view.stdout || '').trim();
368
+ if (view.status !== 0 || !login) return null;
369
+ const perm = spawnSync(
370
+ 'gh',
371
+ ['api', `repos/${repo || '{owner}/{repo}'}/collaborators/${login}/permission`, '-q', '.permission'],
372
+ { encoding: 'utf8' }
373
+ );
374
+ const p = String(perm.stdout || '').trim();
375
+ return perm.status === 0 && p ? p : null;
376
+ }
377
+
378
+ /** The PR body, for the `risk: high` half of the security trigger. '' on any failure. */
379
+ function ghBody(pr, repo) {
380
+ const args = ['pr', 'view', String(pr), '--json', 'body'];
381
+ if (repo) args.push('--repo', repo);
382
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
383
+ if (r.status !== 0) return '';
384
+ try {
385
+ return JSON.parse(r.stdout || '{}').body || '';
386
+ } catch {
387
+ return '';
388
+ }
389
+ }
390
+
391
+ /**
392
+ * The project's review config: `golden-frijoles.config.json` → `review` when present, else the legacy
393
+ * `scripts/review-config.json` (the wave-2 loader, readSection). A missing/invalid config is FATAL:
394
+ * defaulting it would silently mean "never run the security lens".
395
+ */
396
+ export function loadReviewConfig(path = REVIEW_CONFIG_PATH) {
397
+ try {
398
+ const { raw, present } = readSection('review', {
399
+ legacyPath: path,
400
+ onLegacyError: (_p, e) => {
401
+ throw e;
402
+ },
403
+ });
404
+ if (!present) throw new Error(`${path} not found`);
405
+ return parseReviewConfig(raw);
406
+ } catch (e) {
407
+ die(
408
+ `scripts/review-config.json is missing or invalid (${e.message}). It decides which PRs get the security lens — a default would silently mean "never".`
409
+ );
410
+ }
411
+ }
412
+
413
+ /** The PR head sha, pinned before the review runs. null when gh cannot say — never a guess. */
414
+ function ghHeadSha(pr, repo) {
415
+ const args = ['pr', 'view', String(pr), '--json', 'headRefOid'];
416
+ if (repo) args.push('--repo', repo);
417
+ const r = spawnSync('gh', args, { encoding: 'utf8' });
418
+ if (r.status !== 0) return null;
419
+ try {
420
+ return JSON.parse(r.stdout || '{}').headRefOid || null;
421
+ } catch {
422
+ return null;
423
+ }
424
+ }
425
+
426
+ function ghComments(pr, repo) {
427
+ const args = ['pr', 'view', String(pr), '--json', 'comments'];
428
+ if (repo) args.push('--repo', repo);
429
+ const r = spawnSync('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
430
+ if (r.status !== 0) return [];
431
+ try {
432
+ return (JSON.parse(r.stdout || '{}').comments || []).map((c) => c.body || '');
433
+ } catch {
434
+ return [];
435
+ }
436
+ }
437
+
438
+ function postComment(pr, repo, body) {
439
+ const args = ['pr', 'comment', String(pr)];
440
+ if (repo) args.push('--repo', repo);
441
+ args.push('--body-file', '-'); // pipe the body on stdin → no shell-escaping pitfalls
442
+ const r = spawnSync('gh', args, { input: body, encoding: 'utf8' });
443
+ if (r.status !== 0) {
444
+ const first = (r.stderr || '').trim().split('\n')[0] || 'unknown error';
445
+ die(`gh pr comment failed for #${pr}: ${first}`);
446
+ }
447
+ return (r.stdout || '').trim(); // gh prints the comment URL
448
+ }
449
+
450
+ async function main() {
451
+ let {
452
+ pr,
453
+ agent,
454
+ repo,
455
+ force,
456
+ dryRun,
457
+ skipTrivial,
458
+ minLines,
459
+ includeLockfiles,
460
+ lens,
461
+ help,
462
+ builder,
463
+ codeOnly,
464
+ allowUntrustedAuthor,
465
+ paths,
466
+ } = parseArgs(process.argv.slice(2));
467
+ if (help) {
468
+ process.stdout.write(HELP + '\n');
469
+ process.exit(0);
470
+ }
471
+ if (pr !== null && !/^\d+$/.test(String(pr))) die(`PR number must be numeric, got '${pr}'.`);
472
+ // ── Codex re-enabled as a PRIMARY reviewer, 2026-07-26 ──────────────────────────────────────
473
+ // This guard dated from the 2026-07-23 cadence, when Codex was reserved for the builder/architect
474
+ // seat and Agy was the sole review rail. Two things changed it back.
475
+ //
476
+ // First, evidence: on PR #33 Codex opened with a BLOCKING finding on an auth/tenancy surface that
477
+ // four consecutive Agy rounds had read past and finally declared clean — plus two Should-fix the
478
+ // other family had also missed. Roadmap/LEARNINGS.md draws the conclusion: neither family is
479
+ // better, they are blind in different directions, and the rule is to stop when a round from the
480
+ // OTHER family comes back clean, not when your usual reviewer does. A guard that makes the other
481
+ // family unreachable defeats that rule by construction.
482
+ //
483
+ // Second, the constraint that motivated it (Codex quota) has lifted, and Roadmap/WAYS-OF-WORKING's
484
+ // routing table already lists Codex first in the review row. This script was the last place still
485
+ // enforcing the superseded cadence — the docs and the tool disagreed, and the tool won silently,
486
+ // which is exactly the drift class this repo keeps paying for.
487
+ if (!Object.prototype.hasOwnProperty.call(AGENTS, agent)) {
488
+ die(`unknown reviewer '${agent}'; expected one of ${Object.keys(AGENTS).join('|')}`);
489
+ }
490
+
491
+ // ── Builder family ≠ reviewer family (the product owner, 2026-07-26) ───────────────────────────────────
492
+ // Newly reachable now that Codex BUILDS here as well as reviews: `--agent codex` on a Codex-built
493
+ // diff is same-family self-review wearing a cross-review label, which is worse than no review
494
+ // because it gets recorded as one. A hard refusal rather than a warning — the output looks
495
+ // identical either way, so a warning is a thing nobody notices. See reviewersFor().
496
+ const pairingError = checkReviewerPairing(builder, agent);
497
+ if (pairingError) die(pairingError);
498
+ if (builder) {
499
+ process.stderr.write(
500
+ `Builder: ${builder} → reviewer '${agent}' OK (eligible: ${reviewersFor(builder).join(', ')}).\n`
501
+ );
502
+ }
503
+
504
+ if (lens && !LENSES.includes(lens)) die(`unknown --lens '${lens}'; use ${LENSES.join('|')}`);
505
+
506
+ ensureGh();
507
+
508
+ // No <PR#> → resolve the open PR for the current branch and guard against a stale local HEAD, so the
509
+ // FIRST run reviews the right diff. An explicit <PR#> skips both (the deliberate escape hatch).
510
+ if (pr === null) {
511
+ const resolved = resolveCurrentPr({ repo });
512
+ pr = String(resolved.number);
513
+ process.stderr.write(`Resolved PR #${pr} from branch \`${resolved.headRefName}\`.\n`);
514
+
515
+ const localHead = currentHeadSha();
516
+ const action = decideHeadGuard({ localHead, prHeadOid: resolved.headRefOid, force });
517
+ if (action === 'mismatch-block') {
518
+ die(
519
+ `local HEAD (${shortSha(localHead)}) differs from PR #${pr} head (${shortSha(resolved.headRefOid)}) ` +
520
+ `— push first, or pass --force (or an explicit <PR#>) to review anyway.`
521
+ );
522
+ }
523
+ if (action === 'mismatch-force') {
524
+ process.stderr.write(
525
+ `⚠ local HEAD (${shortSha(localHead)}) differs from PR #${pr} head ` +
526
+ `(${shortSha(resolved.headRefOid)}) — proceeding due to --force.\n`
527
+ );
528
+ }
529
+ }
530
+ // An outsider's diff never reaches a reviewer unasked (lib/secret-guard.mjs → decideAuthorTrust).
531
+ const trust = decideAuthorTrust({
532
+ permission: prAuthorPermission(pr, repo),
533
+ allowUntrusted: allowUntrustedAuthor,
534
+ });
535
+ if (!trust.ok) die(trust.why);
536
+ process.stderr.write(`Author check: ${trust.why}.\n`);
537
+
538
+ // The security lens is triggered by the CHANGED PATHS, not by judgement (ways-of-work-lean-pass D7).
539
+ // The router prints both commands, but a hand-run general pass must not silently stand in for a missing
540
+ // security pass — so this run says so, on stderr AND in the posted comment where a PR reader sees it.
541
+ let securityOwed = null;
542
+ if (!lens) {
543
+ const cfg = loadReviewConfig();
544
+ const decision = decideSecurityPass({
545
+ files: ghFiles(pr, repo),
546
+ body: ghBody(pr, repo),
547
+ securityPaths: cfg.securityPaths,
548
+ totalFiles: changedFileCount({ pr, repo }),
549
+ });
550
+ if (decision.run) {
551
+ securityOwed = decision.reason;
552
+ process.stderr.write(
553
+ `⚠ this PR triggers the security lens (${decision.reason}) — run: node scripts/cross-review.mjs ${pr}${repo ? ` --repo ${repo}` : ''} --agent <another-family>${builder ? ` --builder ${builder}` : ''} --lens security\n`
554
+ );
555
+ }
556
+ }
557
+
558
+ // Cost guard (CI): bail before installing/running the reviewer when the diff is trivial/docs-only.
559
+ if (skipTrivial) {
560
+ const { skip, reason } = decideTrivialSkip({ files: ghFiles(pr, repo), minLines });
561
+ if (skip) {
562
+ process.stderr.write(`cross-review skipped (${reason}) — PR #${pr}.\n`);
563
+ process.exit(0);
564
+ }
565
+ }
566
+
567
+ // jev-semantic-guards: resolve the Jev config BEFORE a review is paid for — a malformed jev.config.json
568
+ // throws, and throwing after the reviewer ran would lose its reply and strand the status at `pending`.
569
+ jevContext('review');
570
+
571
+ // Pin the commit being reviewed BEFORE the reviewer runs: a push mid-review would otherwise move the
572
+ // status onto a commit nobody read, and the re-review check needs to tell a new commit from a retry.
573
+ const reviewedSha = ghHeadSha(pr, repo);
574
+
575
+ // Post PENDING before the CLI is even checked. A version-pin mismatch or a dead token exits the
576
+ // script BEFORE the guard runs (observed live on PR #177, where the agy pin refused the run), and an
577
+ // ABSENT status is indistinguishable from "never ran". A stuck `pending` is visibly not-clean.
578
+ if (!dryRun)
579
+ postReviewStatus({
580
+ pr,
581
+ repo,
582
+ state: 'pending',
583
+ lens,
584
+ sha: reviewedSha,
585
+ description: `${AGENTS[agent]} reviewing…`,
586
+ });
587
+
588
+ // Presence-check the CLI we're ACTUALLY about to run. This used to hard-require `agy` on every run
589
+ // regardless of --agent, which was a harmless quirk while agy was the default and codex the only
590
+ // alternative — and becomes a wrong failure now that the roster is four families: without this,
591
+ // `--agent claude` on a machine with no Antigravity installed would die complaining about agy.
592
+ if (agent === 'antigravity') {
593
+ ensureCmd('agy', 'agy not found — install the Antigravity CLI and authenticate it, then retry.');
594
+ checkAgyVersion();
595
+ } else if (agent === 'codex') {
596
+ ensureCmd(
597
+ 'codex',
598
+ 'codex not found — install Codex CLI (https://github.com/openai/codex) and `codex login`.'
599
+ );
600
+ } else if (agent === 'vibe') {
601
+ ensureCmd(
602
+ AGENT_BIN.vibe,
603
+ 'vibe not found — install the Mistral Vibe CLI (`uv tool install mistral-vibe`) and authenticate it, then retry.'
604
+ );
605
+ } else if (agent === 'claude') {
606
+ ensureCmd(
607
+ AGENT_BIN.claude,
608
+ 'claude not found — install Claude Code (https://claude.com/claude-code) and run `claude auth login`, then retry.'
609
+ );
610
+ }
611
+
612
+ // Re-review convergence (D8): a prior pass for THIS lens means Blocking/Important only.
613
+ const reReview = isReReview(ghComments(pr, repo), lens, reviewedSha);
614
+ const prompt = loadPromptBody(promptPathFor(lens)) + (reReview ? RE_REVIEW_NOTE : '');
615
+ const rawDiff = ghDiff(pr, repo);
616
+ let diff = rawDiff;
617
+ if (!includeLockfiles) {
618
+ const stripped = stripGeneratedFileDiffs(rawDiff);
619
+ diff = stripped.diff;
620
+ if (stripped.strippedFiles.length) {
621
+ process.stderr.write(
622
+ `Omitted ${stripped.strippedFiles.length} generated file diff(s) to fit the reviewer's context ` +
623
+ `window: ${stripped.strippedFiles.join(', ')} (pass --include-lockfiles to send the raw diff).\n`
624
+ );
625
+ }
626
+ }
627
+ // ── The code-only subset, and its honesty requirement ───────────────────────────────────────
628
+ // Both argv-based reviewers get THEIR OWN cap. They are the same 256 KB today, which is exactly
629
+ // why hardcoding agy's went unnoticed — the two would diverge silently the day either CLI changed,
630
+ // and vibe would be handed a budget computed against another tool's limit (cross-review, Agy,
631
+ // PR #119). Resolved once, here, so the scope notes and the budget cannot disagree.
632
+ const argvLimit = agent === 'vibe' ? VIBE_ARG_LIMIT : AGY_ARG_LIMIT;
633
+
634
+ // agy takes its prompt in argv, so a sprint-sized PR can exceed that cap and the review
635
+ // refuses to run — which on a high-risk diff is exactly when losing the second family hurts most.
636
+ // Dropping prose usually fits it. But a reviewer who cannot see the sprint doc cannot check the
637
+ // code against its acceptance criteria, so the reduced scope is recorded and posted with the
638
+ // findings rather than left implicit.
639
+ let scopeNote = null;
640
+ if (codeOnly) {
641
+ const codeOnlyDiff = stripDocFileDiffs(diff);
642
+ diff = codeOnlyDiff.diff;
643
+ scopeNote =
644
+ `**Scope: CODE ONLY.** ${codeOnlyDiff.strippedFiles.length} documentation file(s) were ` +
645
+ `withheld from this reviewer to fit a ${argvLimit / 1024} KB input budget, so it did ` +
646
+ `NOT see the sprint docs, the epic README or any migration prose — it could not check the ` +
647
+ `code against its own stated acceptance criteria. Withheld: ` +
648
+ `${codeOnlyDiff.strippedFiles.join(', ') || '(none)'}.`;
649
+ process.stderr.write(`Code-only: withheld ${codeOnlyDiff.strippedFiles.length} doc file(s).\n`);
650
+ }
651
+
652
+ if (paths.length > 0) {
653
+ const scoped = filterDiffToPaths(diff, paths);
654
+ diff = scoped.diff;
655
+ const note =
656
+ `**Scope: ${scoped.keptFiles.length} FILE(S) ONLY.** This reviewer was given a targeted ` +
657
+ `subset of the PR — the diff exceeds a ${argvLimit / 1024} KB input budget in full, so ` +
658
+ `the alternative was no second-family review at all. It saw: ` +
659
+ `${scoped.keptFiles.join(', ')}. It did NOT see ${scoped.droppedFiles.length} other changed ` +
660
+ `file(s), and could not check any of this against the sprint docs.`;
661
+ scopeNote = scopeNote ? `${scopeNote}\n\n${note}` : note;
662
+ process.stderr.write(
663
+ `Scoped to ${scoped.keptFiles.length} file(s), dropped ${scoped.droppedFiles.length}.\n`
664
+ );
665
+ }
666
+
667
+ // ── Attach the full current text of the files this diff touches ─────────────────────────────
668
+ // Only for the two DIFF-ONLY reviewers. `claude` runs deliberately tool-less over stdin and
669
+ // `codex` takes context on stdin without an argv cap, so neither needs (or is helped by) this.
670
+ //
671
+ // Three wrong findings in two days came from a reviewer reasoning about code it could not see —
672
+ // a helper "not defined" that was defined eight lines above the hunk being the clearest. See
673
+ // buildFileContext's header for why agy and vibe get attachment rather than repo access.
674
+ let fileContext = '';
675
+ if (agent === 'antigravity' || agent === 'vibe') {
676
+ // Whatever argv budget the diff has not already spent, less a margin for the prompt and the
677
+ // fences. Under-filling is the safe direction: a review that runs on less context still runs.
678
+ const budget = Math.max(0, argvLimit - Buffer.byteLength(diff, 'utf8') - 16 * 1024);
679
+ const touched = headSidePaths(diff);
680
+ // ── Read from the PR's HEAD COMMIT, not the working tree ──────────────────────────────────
681
+ // This used to be `readFileSync(path)`, i.e. whatever branch happens to be checked out. With
682
+ // stacked sprint branches — this repo's DEFAULT shape (WAYS-OF-WORKING §6) — that is routinely a
683
+ // DIFFERENT branch than the PR under review, and the reviewer then receives the PR's diff
684
+ // alongside another branch's file contents.
685
+ //
686
+ // Not hypothetical: reviewing PR #118 while checked out on its `-s2` child, Mistral Vibe raised a
687
+ // confident Blocking finding that the diff "would fail to compile", because an import present in
688
+ // the diff was absent from the file text it was handed. It reasoned correctly from contradictory
689
+ // inputs. That is this very block's own failure mode inverted (see its header: "three wrong
690
+ // findings in two days came from a reviewer reasoning about code it could not see") — and the
691
+ // inverted form is worse, because the reviewer is confidently wrong rather than visibly blind.
692
+ //
693
+ // The stale-HEAD guard above does NOT cover this: it runs only when <PR#> is omitted, and an
694
+ // explicit <PR#> is documented as skipping it. So the content is pinned here independently, and
695
+ // falls back to the working tree only for an object this clone does not have — saying so.
696
+ const prHeadOid = resolvePrHeadOid(pr, repo);
697
+ // The head object may not be in this clone at all — an unfetched branch, or a FORK PR, whose
698
+ // head `git fetch origin` never brings down. Try once, explicitly, before giving up on it.
699
+ if (prHeadOid) {
700
+ const have = spawnSync('git', ['cat-file', '-e', `${prHeadOid}^{commit}`]);
701
+ if (have.status !== 0) spawnSync('git', ['fetch', '--quiet', 'origin', prHeadOid]);
702
+ }
703
+ const unavailable = [];
704
+ const readAtPrHead = (path) => {
705
+ if (prHeadOid) {
706
+ const shown = spawnSync('git', ['show', `${prHeadOid}:${path}`], {
707
+ encoding: 'utf8',
708
+ maxBuffer: 64 * 1024 * 1024,
709
+ });
710
+ if (shown.status === 0) return shown.stdout;
711
+ }
712
+ // ── OMIT, never substitute the working tree ───────────────────────────────────────────────
713
+ // The first version fell back to `readFileSync` with a warning. Codex was right to call that
714
+ // (PR #119, round 3): a warning on stderr does not stop the review, so the reviewer still
715
+ // receives the PR's diff beside another branch's file — the exact defect this change exists to
716
+ // remove, reintroduced in its own fallback. Fork PRs make it likely rather than rare.
717
+ //
718
+ // Throwing is the omit path: buildFileContext already catches a failing reader and skips that
719
+ // file. Less context is the safe direction — a reviewer that cannot see a file says so;
720
+ // a reviewer shown the WRONG file states a defect that does not exist.
721
+ unavailable.push(path);
722
+ throw new Error(`not available at PR head: ${path}`);
723
+ };
724
+ const selection = buildFileContext(touched, readAtPrHead, budget);
725
+ if (unavailable.length > 0) {
726
+ process.stderr.write(
727
+ `\u26a0 ${unavailable.length} file(s) could not be read at PR #${pr}'s head` +
728
+ `${prHeadOid ? ` (${shortSha(prHeadOid)})` : ' (head could not be resolved)'} and were ` +
729
+ `OMITTED rather than read from the working tree: ${unavailable.join(', ')}\n`
730
+ );
731
+ }
732
+ fileContext = renderFileContext(selection);
733
+ if (selection.attached.length || selection.omitted.length) {
734
+ process.stderr.write(
735
+ `Attached ${selection.attached.length} whole file(s) (${Math.round(selection.bytes / 1024)} KB); ` +
736
+ `${selection.omitted.length} did not fit the budget.\n`
737
+ );
738
+ }
739
+ }
740
+
741
+ let usedAgyModel = null;
742
+ const { findings, fellBack } = runReview(
743
+ agent,
744
+ fileContext ? `${prompt}\n\n${fileContext}` : prompt,
745
+ diff,
746
+ { builder, onModel: (m) => (usedAgyModel = m) }
747
+ );
748
+ // The family that actually answered: after a codex→agy heal, the comment must not say "Codex".
749
+ const answered = fellBack ? 'antigravity' : agent;
750
+ const reviewerLabel = fellBack ? `${AGENTS.antigravity} — Codex unavailable` : AGENTS[agent];
751
+
752
+ // Never PUBLISH a reply that carries this machine's secrets (lib/secret-guard.mjs): a reviewer fed an
753
+ // injected diff can still read host files (codex's read-only sandbox allows reads). Checked FIRST, before
754
+ // the output guard — that guard quotes the reply's opening text in a PUBLIC status description, and asks
755
+ // Jev about the reply (egress) — so a secret-carrying reply reaches neither (pr-reviewer round 3 on #188).
756
+ // The status posted here names no reply text. The reply is printed locally, redacted.
757
+ const secrets = findSecretLeaks(findings, { values: collectSecretValues({ root: projectRoot() }) });
758
+ if (secrets.leaks.length) {
759
+ const what = secrets.leaks.map((l) => l.name).join(', ');
760
+ if (!dryRun)
761
+ postReviewStatus({
762
+ pr,
763
+ repo,
764
+ state: 'failure',
765
+ lens,
766
+ description: `${reviewerLabel}: reply withheld — it carried secret-shaped text`,
767
+ sha: reviewedSha,
768
+ });
769
+ process.stderr.write(
770
+ `\n───── ${reviewerLabel}'s reply, WITHHELD (redacted: ${what}) ─────\n${secrets.redacted}\n───── end of reply ─────\n`
771
+ );
772
+ die(
773
+ `${reviewerLabel}'s reply carried ${what} and was NOT posted. If this is a real leak, rotate the ` +
774
+ `credential and treat the diff as hostile; if it is a false positive, post the redacted text by hand.`
775
+ );
776
+ }
777
+
778
+ // THE GUARD (ways-of-work-lean-pass D9). With one external pass, a CLI that exits 0 with nothing to say
779
+ // reads exactly like a clean review and nothing contradicts it. A structureless reply FAILS the run and
780
+ // fails the PR's `cross-review/<lens>` status rather than posting a comment that looks like a pass.
781
+ // jev-semantic-guards D5: Jev decides "is this a real review?" per jev.config.json → rails.review.mode;
782
+ // `assertReviewOutput` is the fallback when Jev cannot look or is unsure, and the whole of it when `off`.
783
+ const verdict = await judgeReviewOutput(findings, { sha: reviewedSha });
784
+ process.stderr.write(`review guard: ${verdict.reason}\n`);
785
+ if (!verdict.ok) {
786
+ if (!dryRun) {
787
+ const st = postReviewStatus({
788
+ pr,
789
+ repo,
790
+ state: 'failure',
791
+ lens,
792
+ description: `${reviewerLabel}: ${verdict.reason}`,
793
+ sha: reviewedSha,
794
+ });
795
+ process.stderr.write(
796
+ st.posted
797
+ ? `✗ marked cross-review/${lens || 'general'} FAILED on PR #${pr}.\n`
798
+ : `✗ could not post the failing status (${st.detail}).\n`
799
+ );
800
+ }
801
+ // NEVER destroy a reply the run paid for: a false reject would otherwise cost a full re-run, which is
802
+ // exactly how a guard trains people to bypass it.
803
+ process.stderr.write(
804
+ `\n───── ${reviewerLabel}'s full reply, rejected by the output guard ─────\n${findings || '(empty)'}\n───── end of reply ─────\n`
805
+ );
806
+ die(`${reviewerLabel} did not return a review: ${verdict.reason}`);
807
+ }
808
+
809
+ const body =
810
+ buildComment(AGENTS[agent], scopeNote ? `${scopeNote}\n\n---\n\n${findings}` : findings, fellBack, {
811
+ lens,
812
+ model: resolveReviewModel(agent, fellBack, { usedAgyModel }),
813
+ version: cliVersionNote(answered),
814
+ reReview,
815
+ securityOwed,
816
+ }) +
817
+ reviewMarker({ lens, sha: reviewedSha }) +
818
+ jevMarker(verdict);
819
+ if (dryRun) {
820
+ process.stdout.write(body);
821
+ process.stderr.write('\n(dry-run — no comment posted)\n');
822
+ } else {
823
+ const url = postComment(pr, repo, body);
824
+ process.stderr.write(`✓ Review comment posted${url ? `: ${url}` : ''}\n`);
825
+ const st = postReviewStatus({
826
+ pr,
827
+ repo,
828
+ state: 'success',
829
+ lens,
830
+ description: `review produced by ${reviewerLabel} (${verdict.reason}) — not a verdict`,
831
+ sha: reviewedSha,
832
+ });
833
+ process.stderr.write(
834
+ st.posted
835
+ ? `✓ cross-review/${lens || 'general'} status: success.\n`
836
+ : `⚠ review posted but its status did not (${st.detail}).\n`
837
+ );
838
+ }
839
+ }
840
+
841
+ // Guarded so importing this module for its pure helpers does not run a review.
842
+ // realpath on both sides: through a symlinked path a plain compare is false and the script exits 0 having
843
+ // done nothing (#189 review).
844
+ const isMain = (() => {
845
+ try {
846
+ return (
847
+ !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
848
+ );
849
+ } catch {
850
+ return false;
851
+ }
852
+ })();
853
+ if (isMain)
854
+ main().catch((e) => {
855
+ process.stderr.write(`cross-review: ${e?.message || e}\n`);
856
+ process.exit(1);
857
+ });