@golden-frijoles/kit 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-state.mjs +716 -0
- package/dist/cross-agent-doctor.mjs +617 -0
- package/dist/cross-review.mjs +857 -0
- package/dist/cross-review.prompt.md +100 -0
- package/dist/cross-review.security.prompt.md +92 -0
- package/dist/lib/review-guard.mjs +478 -0
- package/dist/lib/secret-guard.mjs +212 -0
- package/dist/lib/session-journal.mjs +99 -0
- package/dist/review-config.json +25 -0
- package/dist/review-route.mjs +234 -0
- package/dist/session-note.mjs +127 -0
- package/dist/session-resume.mjs +953 -0
- package/package.json +1 -1
|
@@ -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
|
+
});
|