@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,212 @@
1
+ // secret-guard.mjs — never POST a reviewer's reply that carries one of this machine's secrets.
2
+ //
3
+ // ── Why this exists (distribute-what-we-use S1, the fresh pr-reviewer's round 2 on #188) ──────────────
4
+ // A reviewer CLI is fed an attacker-controllable diff. The rail removes every tool it can (Vibe runs with
5
+ // all tools disabled, Devin is refused, Claude runs `--tools ""`, codex runs `--sandbox read-only
6
+ // --ignore-user-config`), but codex's read-only sandbox still lets it READ host files, and there is no
7
+ // flag that stops that. An injected diff can therefore ask for `cat .env.local`, and cross-review would
8
+ // post the answer as a comment — on a public repo, to anyone.
9
+ //
10
+ // Refusing codex would leave no working reviewer, so the CHANNEL is closed instead: before anything is
11
+ // posted, the reply is checked for (1) any value from the project's own env files or from this process's
12
+ // secret-named env vars, and (2) the shapes of the credentials a developer machine commonly holds. A match
13
+ // fails the run and posts nothing; the reply is printed locally (it is the operator's own machine) with
14
+ // every match redacted, so a false positive costs a look, never a leak.
15
+ //
16
+ // Deliberately NOT a general secret scanner: GitHub push protection and CodeQL own that for commits. This
17
+ // guards one path — a model's text on its way to a PR comment. Zero npm deps.
18
+
19
+ import { existsSync, readdirSync, readFileSync } from 'node:fs';
20
+ import { join } from 'node:path';
21
+ import { homedir } from 'node:os';
22
+
23
+ /** Shorter values are too likely to occur in ordinary review prose ("development", a port, a flag). */
24
+ export const MIN_SECRET_LENGTH = 16;
25
+
26
+ /** Credential shapes with a distinctive prefix — low false-positive by construction. */
27
+ export const SECRET_SHAPES = Object.freeze([
28
+ { name: 'private key block', re: /-----BEGIN [A-Z ]*PRIVATE KEY-----/ },
29
+ { name: 'GitHub token', re: /\bgh[pousr]_[A-Za-z0-9]{30,}\b/ },
30
+ { name: 'GitHub fine-grained token', re: /\bgithub_pat_[A-Za-z0-9_]{40,}\b/ },
31
+ { name: 'OpenAI/Anthropic-style key', re: /\bsk-(?:ant-|proj-)?[A-Za-z0-9_-]{24,}\b/ },
32
+ { name: 'Slack token', re: /\bxox[abposr]-[A-Za-z0-9-]{10,}\b/ },
33
+ { name: 'Slack webhook', re: /hooks\.slack\.com\/services\/[A-Za-z0-9/]{20,}/ },
34
+ { name: 'Telegram bot token', re: /\b\d{8,10}:[A-Za-z0-9_-]{35}\b/ },
35
+ { name: 'AWS access key id', re: /\bAKIA[0-9A-Z]{16}\b/ },
36
+ { name: 'Supabase access token', re: /\bsbp_[a-f0-9]{40}\b/ },
37
+ { name: 'npm token', re: /\bnpm_[A-Za-z0-9]{36}\b/ },
38
+ { name: 'Supabase secret key', re: /\bsb_secret_[A-Za-z0-9_-]{16,}\b/ },
39
+ // A signed JWT (header.payload.signature, both JSON parts base64url `eyJ…`): service-role keys are JWTs.
40
+ // An opaque base64 run long enough to carry a key. Real reviews quote code, not 80-char blobs; this is
41
+ // defence in depth against an injected "encode it" (the author-trust check below is the real control).
42
+ { name: 'opaque base64 blob', re: /[A-Za-z0-9+/]{80,}={0,2}/ },
43
+ { name: 'JWT', re: /\beyJ[\w-]{10,}\.eyJ[\w-]{10,}\.[\w-]{10,}/ },
44
+ ]);
45
+
46
+ const SECRET_KEY_RE = /(KEY|TOKEN|SECRET|PASSWORD|PASS|PRIVATE|CREDENTIAL|WEBHOOK|DSN)/i;
47
+
48
+ /** `KEY=value` pairs from a dotenv-shaped text; quotes stripped, comments and blanks ignored. */
49
+ export function parseEnvValues(text) {
50
+ const out = [];
51
+ for (const line of String(text || '').split('\n')) {
52
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
53
+ if (!m) continue;
54
+ const raw = m[2].trim();
55
+ // A quoted value ends at its closing quote (a trailing `# comment` is dropped); an unquoted one ends
56
+ // before ` #` (dotenv's inline-comment rule).
57
+ const q = /^(['"])(.*?)\1(?:\s+#.*)?$/.exec(raw);
58
+ const v = q ? q[2] : raw.replace(/\s+#.*$/, '').trim();
59
+ if (v) out.push({ key: m[1], value: v });
60
+ }
61
+ return out;
62
+ }
63
+
64
+ const ENV_FILE_RE = /^\.env(?:rc|\..+)?$/;
65
+ const SKIP_DIRS = new Set(['node_modules', '.git', '.next', 'dist', 'build', '.vercel', '.turbo']);
66
+
67
+ /**
68
+ * Every `.env*` file (and `.envrc`) at the root and up to `depth` directories below it — a monorepo keeps
69
+ * its app's secrets in `apps/<app>/.env.local`, not at the root. `.example` files are templates, not secrets.
70
+ */
71
+ export function envFiles(root, { depth = 2, list = readdirSync, exists = existsSync } = {}) {
72
+ const out = [];
73
+ const walk = (dir, left) => {
74
+ let names;
75
+ try {
76
+ names = exists(dir) ? list(dir, { withFileTypes: true }) : [];
77
+ } catch {
78
+ return;
79
+ }
80
+ for (const d of names) {
81
+ const name = typeof d === 'string' ? d : d.name;
82
+ const isDir = typeof d === 'string' ? false : d.isDirectory();
83
+ if (isDir) {
84
+ if (left > 0 && !SKIP_DIRS.has(name)) walk(join(dir, name), left - 1);
85
+ } else if (ENV_FILE_RE.test(name) && !/\.example$/.test(name)) out.push(join(dir, name));
86
+ }
87
+ };
88
+ walk(root, depth);
89
+ return out;
90
+ }
91
+
92
+ /**
93
+ * The operator's own credential stores, by path under $HOME. A collaborator's diff can still steer a reviewer
94
+ * into reading one of these (codex's read-only sandbox reads the whole disk), and their values often have no
95
+ * distinctive shape (an AWS secret access key is 40 plain characters) — so their VALUES are matched verbatim.
96
+ * Only `key = value` / `key: value` style lines are read; nothing is stored or printed.
97
+ */
98
+ export const HOME_CREDENTIAL_FILES = Object.freeze([
99
+ '.aws/credentials',
100
+ '.netrc',
101
+ '.npmrc',
102
+ '.pypirc',
103
+ '.config/gh/hosts.yml',
104
+ '.docker/config.json',
105
+ '.git-credentials',
106
+ '.codex/auth.json',
107
+ ]);
108
+
109
+ /** Pure: every long value on a `key = value`, `key: value`, `"key": "value"` or `login x password y` line. */
110
+ export function credentialValues(text) {
111
+ const out = [];
112
+ for (const line of String(text || '').split('\n')) {
113
+ // The value after the LAST `=`/`:` on the line (`//registry.npmjs.org/:_authToken=npm_…` keeps only the
114
+ // token), and the word after `password` (netrc).
115
+ // Trailing base64 `=` padding is part of the value (`_auth=…==`, docker "auth": "…="); a URL remainder
116
+ // (`registry=https://…` → `//registry…`) is configuration, not a secret (round 5 on #188).
117
+ const kv = /[=:]\s*"?([^\s"',=:]{16,}={0,2})"?\s*,?\s*$/.exec(line);
118
+ // Skip ONLY a URL's `scheme://` remainder; a base64 value can itself start with `//` (round 6).
119
+ if (kv && !(kv[1].startsWith('//') && /https?:$/i.test(line.slice(0, kv.index + 1)))) out.push(kv[1]);
120
+ const pw = /\bpassword\s+(\S{16,})/.exec(line);
121
+ if (pw) out.push(pw[1]);
122
+ const url = /https?:\/\/[^:\s]+:([^@\s]{16,})@/.exec(line);
123
+ if (url) out.push(url[1]);
124
+ }
125
+ return out;
126
+ }
127
+
128
+ /**
129
+ * The secret VALUES this machine could leak for this project: every value in the root's `.env*` files (a
130
+ * dotenv file is secrets by convention; nested ones too, see envFiles), plus this process's env vars whose NAME says secret. Values
131
+ * shorter than MIN_SECRET_LENGTH are skipped. Unreadable files are skipped — could not look is not a leak.
132
+ */
133
+ export function collectSecretValues({
134
+ root = process.cwd(),
135
+ env = process.env,
136
+ home = homedir(),
137
+ read = readFileSync,
138
+ list = readdirSync,
139
+ exists = existsSync,
140
+ } = {}) {
141
+ const values = new Set();
142
+ for (const rel of home ? HOME_CREDENTIAL_FILES : []) {
143
+ try {
144
+ const p = join(home, rel);
145
+ if (exists(p)) for (const v of credentialValues(read(p, 'utf8'))) values.add(v);
146
+ } catch {
147
+ /* unreadable: skip */
148
+ }
149
+ }
150
+ for (const file of envFiles(root, { list, exists })) {
151
+ try {
152
+ for (const { value } of parseEnvValues(read(file, 'utf8')))
153
+ if (value.length >= MIN_SECRET_LENGTH) values.add(value);
154
+ } catch {
155
+ /* unreadable: skip */
156
+ }
157
+ }
158
+ for (const [k, v] of Object.entries(env || {}))
159
+ if (SECRET_KEY_RE.test(k) && typeof v === 'string' && v.length >= MIN_SECRET_LENGTH) values.add(v);
160
+ return [...values];
161
+ }
162
+
163
+ /**
164
+ * Pure: which secrets does `text` carry? Returns `{ leaks: [{kind, name}], redacted }`, where `redacted`
165
+ * is `text` with every match replaced by `[REDACTED <name>]`. Names only — never the value itself.
166
+ */
167
+ export function findSecretLeaks(text, { values = [] } = {}) {
168
+ let redacted = String(text || '');
169
+ const leaks = [];
170
+ for (const v of values) {
171
+ if (v && redacted.includes(v)) {
172
+ leaks.push({ kind: 'env-value', name: `an env value (${v.length} chars)` });
173
+ redacted = redacted.split(v).join('[REDACTED env value]');
174
+ }
175
+ }
176
+ for (const { name, re } of SECRET_SHAPES) {
177
+ const g = new RegExp(re.source, re.flags.includes('g') ? re.flags : `${re.flags}g`);
178
+ if (g.test(redacted)) {
179
+ leaks.push({ kind: 'shape', name });
180
+ redacted = redacted.replace(new RegExp(re.source, g.flags), `[REDACTED ${name}]`);
181
+ }
182
+ }
183
+ return { leaks, redacted };
184
+ }
185
+
186
+ // ── Who wrote the diff decides whether a reviewer may read it at all (codex security lens on #188) ─────
187
+ // No reviewer flag stops codex READING host files, and no string matcher catches a secret the model was
188
+ // told to encode. What closes the realistic threat on a public repo is the input: a hostile diff comes from
189
+ // someone without write access (a fork PR). So cross-review refuses such a PR unless the operator says so.
190
+
191
+ /** Repo permissions that can already push code here — their diff is not a new attacker. */
192
+ export const TRUSTED_PERMISSIONS = Object.freeze(['admin', 'maintain', 'write']);
193
+
194
+ /**
195
+ * Pure: may a reviewer read this PR? `permission` is the author's repo permission as GitHub reports it
196
+ * (`null` when it could not be read — treated as untrusted: could-not-look must not open the door).
197
+ */
198
+ export function decideAuthorTrust({ permission, allowUntrusted = false }) {
199
+ if (TRUSTED_PERMISSIONS.includes(permission)) return { ok: true, why: `author has ${permission} access` };
200
+ if (allowUntrusted)
201
+ return {
202
+ ok: true,
203
+ why: `author has ${permission ?? 'unknown'} access — allowed by --allow-untrusted-author`,
204
+ };
205
+ return {
206
+ ok: false,
207
+ why:
208
+ `the PR author has ${permission ?? 'UNKNOWN'} access to this repo. A reviewer fed an outsider's diff can be ` +
209
+ `steered by it into reading this machine's files; review it only after reading the diff yourself, then ` +
210
+ `re-run with --allow-untrusted-author.`,
211
+ };
212
+ }
@@ -0,0 +1,99 @@
1
+ // session-journal.mjs — pure builder/parser for the session-continuity intent journal (README.md D2).
2
+ // Shared by scripts/session-note.mjs (writer) and scripts/session-resume.mjs (reader) so the line format
3
+ // has exactly one definition. Zero I/O in this file on purpose — it's the pure seam both scripts unit-test
4
+ // against without touching git/gh/a branch.
5
+ //
6
+ // The journal is APPEND-ONLY and intent-only (D2): timestamp, session label, kind, one line of text,
7
+ // optional refs. No status field, no "done" marker, no update/delete — the history is the point. It lives
8
+ // on `claude/session-journal` via scripts/lib/log-branch.mjs (reused unchanged), one JSON object per line
9
+ // (JSONL, matching standup.mjs's standups.log convention already on the same kind of `claude/`-branch log).
10
+ //
11
+ // JOURNAL_PATH is a flat filename — log-branch.mjs's writeLogToBranch requires this (git mktree only
12
+ // builds a single-level tree; a nested path fails loud there, not here).
13
+
14
+ export const JOURNAL_BRANCH = 'claude/session-journal';
15
+ export const JOURNAL_PATH = 'session-journal.jsonl';
16
+
17
+ export const VALID_KINDS = ['decision', 'doing', 'next', 'blocked', 'declined'];
18
+
19
+ export function isValidKind(kind) {
20
+ return VALID_KINDS.includes(kind);
21
+ }
22
+
23
+ // A stable per-day label so entries from the same calendar day (UTC) group without an agent having to
24
+ // invent or remember one. `now` is injectable for tests.
25
+ export function defaultSessionLabel(now = new Date()) {
26
+ return now.toISOString().slice(0, 10);
27
+ }
28
+
29
+ // "PR#312,D4" → ["PR#312", "D4"]. Accepts an array too (already-split callers), undefined/empty → [].
30
+ export function parseRefs(refs) {
31
+ if (!refs) return [];
32
+ if (Array.isArray(refs)) return refs.map((r) => String(r).trim()).filter(Boolean);
33
+ return String(refs)
34
+ .split(',')
35
+ .map((r) => r.trim())
36
+ .filter(Boolean);
37
+ }
38
+
39
+ // Pure line-builder — THE unit under test, no I/O (D1's "pure line-builder unit-tested separately from
40
+ // the I/O" acceptance). Throws on a bad kind or empty text — those are usage errors the CLI turns into a
41
+ // loud exit 1, distinct from D1's "failed append" soft-fail (that's about the I/O call, not this).
42
+ export function buildJournalEntry({ kind, text, session, refs, ts, now } = {}) {
43
+ if (!isValidKind(kind)) {
44
+ throw new Error(`invalid --kind "${kind}" — must be one of: ${VALID_KINDS.join(' | ')}`);
45
+ }
46
+ // Normalise embedded line breaks to a single space. JSON.stringify escapes them so the FILE stays
47
+ // one-line-per-entry, but the rendered report prints `text` verbatim — so `$'decision A\nfake entry'`
48
+ // displayed as two journal lines, one of which never happened. A journal that can be made to show
49
+ // fabricated entries is worse than no journal, and this is an intent record a later agent trusts.
50
+ const trimmed = (text || '').replace(/[\r\n]+/g, ' ').trim();
51
+ if (!trimmed) {
52
+ throw new Error('journal text must not be empty');
53
+ }
54
+ return {
55
+ ts: ts || (now || new Date()).toISOString(),
56
+ session: session || defaultSessionLabel(now),
57
+ kind,
58
+ text: trimmed,
59
+ refs: parseRefs(refs),
60
+ };
61
+ }
62
+
63
+ // One JSON line + trailing newline — the exact bytes appended to the journal file.
64
+ export function serializeEntry(entry) {
65
+ return `${JSON.stringify(entry)}\n`;
66
+ }
67
+
68
+ // Parses journal file content into entries, oldest→newest. A single corrupt/partial line is DROPPED, not
69
+ // fatal — an append-only log must survive a bad line (e.g. a torn write) rather than making the whole
70
+ // journal unreadable. Returns [] for empty/null content (the "no journal yet" case).
71
+ export function parseJournal(content) {
72
+ if (!content) return [];
73
+ return content
74
+ .split('\n')
75
+ .map((l) => l.trim())
76
+ .filter(Boolean)
77
+ .map((l) => {
78
+ try {
79
+ return JSON.parse(l);
80
+ } catch {
81
+ return null;
82
+ }
83
+ })
84
+ .filter((e) => e && typeof e === 'object');
85
+ }
86
+
87
+ // The last N entries, still in chronological (oldest→newest) order — D3's "then the last N journal
88
+ // lines" section reads top-to-bottom as a mini-timeline, not most-recent-first.
89
+ export function lastNEntries(entries, n) {
90
+ if (!Array.isArray(entries) || n <= 0) return [];
91
+ return entries.slice(Math.max(0, entries.length - n));
92
+ }
93
+
94
+ // One human-readable line per entry, for resume's "last N" section and any console echo.
95
+ export function formatEntry(entry) {
96
+ const refs = entry.refs && entry.refs.length ? ` [${entry.refs.join(', ')}]` : '';
97
+ const session = entry.session ? ` (${entry.session})` : '';
98
+ return `[${entry.kind}] ${entry.ts}${session} — ${entry.text}${refs}`;
99
+ }
@@ -0,0 +1,25 @@
1
+ {
2
+ "_about": "Which PRs get which review layers (ways-of-work-lean-pass D5/D7). reviewScope: 'every-pr' = the fresh reviewer + one external general pass on every non-trivial PR; 'security-paths-only' = only when a securityPaths glob is touched (or the body declares risk: high). securityPaths ALSO decides the lean external security lens in every scope. TEMPLATE FILL-IN: replace these globs with your project's real money/auth/migration paths. NOTE: a basename glob like `**/*webhook*` matches a FILE named for it; in an App Router every route file is `route.ts`, so each basename glob is paired with a `/**` directory form.",
3
+ "reviewScope": "every-pr",
4
+ "securityPaths": [
5
+ "**/auth/**",
6
+ "**/*webhook*",
7
+ "**/*webhook*/**",
8
+ "**/checkout/**",
9
+ "**/payments/**",
10
+ "**/*stripe*",
11
+ "**/*stripe*/**",
12
+ "middleware.ts",
13
+ "**/middleware.ts",
14
+ "supabase/migrations/*.sql",
15
+ "**/*secret*",
16
+ "**/*secret*/**",
17
+ ".github/workflows/**",
18
+ ".claude/settings.json",
19
+ "scripts/review-route.mjs",
20
+ "scripts/review-config.json",
21
+ "scripts/cross-review.mjs",
22
+ "scripts/lib/review-guard.mjs",
23
+ "golden-frijoles.config.json"
24
+ ]
25
+ }
@@ -0,0 +1,234 @@
1
+ #!/usr/bin/env node
2
+ // review-route.mjs — WHICH families review this PR. One rule, not a table.
3
+ //
4
+ // ── The rule ──────────────────────────────────────────────────────────────────────────────────────
5
+ // The highest-preference family that did NOT build the diff runs the general pass.
6
+ // If the PR touches a security path, the NEXT eligible family runs the lean security lens.
7
+ // The fresh `pr-reviewer` subagent runs too, in whatever scope the project's review-config.json sets.
8
+ //
9
+ // That is the whole policy. It replaces a 273-line router with a four-row table, a two-passes-per-PR
10
+ // count, a HIGH-only branch on the fresh reviewer, and the REFUND-ASK / --fallback-after / DARK-layer
11
+ // protocol (ways-of-work-lean-pass S2.5 + S2.8). A capped family now simply falls to the next one:
12
+ // external quota is not worth a human round-trip when the next family is one line away.
13
+ //
14
+ // ── The two guards that survive, and why ──────────────────────────────────────────────────────────
15
+ // 1. A family NEVER reviews its own diff. With several families building, the default agent flag on a
16
+ // Codex-built diff is Codex reviewing Codex — a same-family pass wearing a cross-family label, and
17
+ // a silent downgrade nothing in a hand-driven flow would catch.
18
+ // 2. The security lens prefers a DIFFERENT family from the general pass, so the two external reads
19
+ // bring different blind spots. With only one family available it runs both prompts and SAYS SO —
20
+ // a missing layer that reads like a clean one is worse than no layer.
21
+ //
22
+ // Usage:
23
+ // node scripts/review-route.mjs --builder claude <PR#> [--repo owner/repo] [--security] [--json]
24
+ // [--exclude <family>] # a family is quota-capped: route past it
25
+ //
26
+ // `--security` forces the lens on; without it the trigger is the PR's own changed paths
27
+ // (scripts/review-config.json → securityPaths), which is what makes it un-skippable by judgement.
28
+ //
29
+ // Zero npm deps — Node 18+. Pure policy exported for node:test; the CLI is a thin shell.
30
+
31
+ import { spawnSync } from 'node:child_process';
32
+ import { writeSync, realpathSync } from 'node:fs';
33
+ import { dirname, join } from 'node:path';
34
+ import { fileURLToPath } from 'node:url';
35
+ import { die, need, hasCmd, AGENT_BIN } from './lib/cross-agent-cli.mjs';
36
+ import { changedFileCount, decideSecurityPass, parseReviewConfig } from './lib/review-guard.mjs';
37
+ import { readSection } from './lib/config.mjs';
38
+
39
+ const __dirname = dirname(fileURLToPath(import.meta.url));
40
+
41
+ /** Every family that can build — which is exactly why guard 1 exists. */
42
+ export const BUILDERS = ['claude', 'codex', 'agy', 'vibe'];
43
+
44
+ /**
45
+ * Preference order. One editable list, not roles hardcoded per pass.
46
+ * codex first: the strongest observed review signal here. agy and vibe are independent quota pools.
47
+ * claude LAST — not because it reviews badly, but because Claude capacity is usually the thing BUILDING.
48
+ */
49
+ export const PREFERENCE = ['codex', 'agy', 'vibe', 'claude'];
50
+
51
+ /** `cross-review.mjs --agent` takes the long name for antigravity; the rest match. */
52
+ export const AGENT_FLAG = { codex: 'codex', agy: 'antigravity', vibe: 'vibe', claude: 'claude' };
53
+
54
+ /**
55
+ * THE POLICY. Pure — no I/O, no availability probing — so it reads as one decision and is pinned by tests.
56
+ * Returns { general, security, notes }. `security` is null when the lens is not triggered.
57
+ */
58
+ export function planReview({ builder, available = PREFERENCE, securityPass = false }) {
59
+ if (!BUILDERS.includes(builder))
60
+ throw new Error(`unknown builder '${builder}' (expected: ${BUILDERS.join(' | ')})`);
61
+ const eligible = PREFERENCE.filter((f) => f !== builder && available.includes(f));
62
+ const general = eligible[0] ?? null;
63
+ // Prefer a different family for the security lens; fall back to the general one rather than skipping.
64
+ const security = securityPass ? (eligible[1] ?? general) : null;
65
+
66
+ const notes = [];
67
+ if (general)
68
+ notes.push(
69
+ `${general} runs the general pass because ${builder} built it — a family never reviews its own diff.`
70
+ );
71
+ else
72
+ notes.push(
73
+ `NO external family is available: the cross-family layer is DARK for this PR. Say so in the PR body rather than letting a missing layer look like a clean one.`
74
+ );
75
+ if (securityPass) {
76
+ if (security && security !== general)
77
+ notes.push(
78
+ `${security} runs the security lens — a different family from the general pass, so the two reads have different blind spots.`
79
+ );
80
+ else if (security)
81
+ notes.push(
82
+ `${security} runs BOTH passes (no second family available) — two prompts, one family. Record that in the PR body: family independence is short here.`
83
+ );
84
+ else notes.push(`The security lens is triggered but no family can run it. Say so in the PR body.`);
85
+ }
86
+ notes.push(
87
+ 'The fresh `pr-reviewer` subagent runs as well — context independence is a different axis from family independence.'
88
+ );
89
+ return { builder, general, security, notes };
90
+ }
91
+
92
+ /** The exact commands to run, in order. Pure. */
93
+ export function renderPlan(plan, pr, repo) {
94
+ // `--builder` rides along so cross-review's pairing guard (and its codex→agy heal re-check) fire in
95
+ // normal use, not only when someone remembers to pass it (distribute-what-we-use S1, pr-reviewer B3).
96
+ const arg = (f) =>
97
+ `node scripts/cross-review.mjs ${pr}${repo ? ` --repo ${repo}` : ''} --agent ${AGENT_FLAG[f]} --builder ${plan.builder}`;
98
+ const l = [
99
+ `Review plan — PR #${pr}${repo ? ` (${repo})` : ''}`,
100
+ ` built by: ${plan.builder}`,
101
+ ` general pass: ${plan.general || '⚠ NONE AVAILABLE'}`,
102
+ ` security lens: ${plan.security || 'not triggered'}`,
103
+ '',
104
+ ];
105
+ for (const n of plan.notes) l.push(` · ${n}`);
106
+ l.push('');
107
+ l.push(' Run:');
108
+ if (plan.general) l.push(` ${arg(plan.general)}`);
109
+ if (plan.security) l.push(` ${arg(plan.security)} --lens security`);
110
+ l.push(` then: use the pr-reviewer subagent on PR #${pr}`);
111
+ return l.join('\n');
112
+ }
113
+
114
+ /** Changed files + body for the security trigger. Degrades to "not triggered" only with a loud note. */
115
+ function prFacts(pr, repo, deps = {}) {
116
+ const run = deps.spawn ?? spawnSync;
117
+ const args = ['pr', 'view', String(pr), '--json', 'files,body'];
118
+ if (repo) args.push('--repo', repo);
119
+ const r = run('gh', args, { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 });
120
+ if (r.status !== 0) return null;
121
+ try {
122
+ const j = JSON.parse(r.stdout);
123
+ return { files: j.files || [], body: j.body || '' };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+
129
+ function main() {
130
+ const argv = process.argv.slice(2);
131
+ let builder = null;
132
+ let pr = null;
133
+ let repo = null;
134
+ let json = false;
135
+ let forceSecurity = false;
136
+ let tierIgnored = false;
137
+ const excluded = [];
138
+ for (let i = 0; i < argv.length; i++) {
139
+ const a = argv[i];
140
+ if (a === '--builder') builder = need(argv[++i], '--builder');
141
+ else if (a === '--repo') repo = need(argv[++i], '--repo');
142
+ else if (a === '--json') json = true;
143
+ else if (a === '--security') forceSecurity = true;
144
+ else if (a === '--tier') {
145
+ // Accepted so an old invocation does not die, but SAID OUT LOUD: the tier no longer selects
146
+ // reviewers, and silently ignoring it would let an old `--tier high` call believe it had asked
147
+ // for the security lens.
148
+ argv[++i];
149
+ tierIgnored = true;
150
+ } else if (a === '--exclude') excluded.push(need(argv[++i], '--exclude'));
151
+ else if (!a.startsWith('-') && pr === null) pr = a;
152
+ else die(`unknown argument '${a}'`);
153
+ }
154
+ if (!builder) die('--builder is required (who wrote the diff): ' + BUILDERS.join(' | '));
155
+ if (!pr) die('a PR number is required');
156
+ if (!/^\d+$/.test(String(pr))) die(`PR number must be numeric, got '${pr}'.`);
157
+ if (!BUILDERS.includes(builder)) die(`unknown --builder '${builder}' (expected: ${BUILDERS.join(' | ')})`);
158
+
159
+ // The `review` section of golden-frijoles.config.json over scripts/review-config.json (D9).
160
+ const { raw: reviewRaw, present: reviewPresent } = readSection('review', {
161
+ legacyPath: join(__dirname, 'review-config.json'),
162
+ onLegacyError: (_p, e) => {
163
+ throw e;
164
+ },
165
+ });
166
+ if (!reviewPresent)
167
+ die('scripts/review-config.json not found, and golden-frijoles.config.json has no review section');
168
+ const config = parseReviewConfig(reviewRaw);
169
+ let securityPass = forceSecurity;
170
+ let trigger = forceSecurity ? 'forced with --security' : null;
171
+ if (!forceSecurity) {
172
+ const facts = prFacts(pr, repo);
173
+ if (!facts && hasCmd('gh')) {
174
+ // gh is installed and still could not read the PR: a wrong number or --repo, expired auth, the
175
+ // network. A route printed for a PR that may not exist is worse than stopping (#189 review, S1).
176
+ die(
177
+ `could not read PR #${pr}'s changed files — the security trigger is UNKNOWN, not false. Check the PR ` +
178
+ 'number and --repo, and `gh auth status`; re-run when gh works, or pass --security.'
179
+ );
180
+ } else if (!facts) {
181
+ // No gh at all — a stranger's machine. UNKNOWN is not "no security path", but refusing to print a route
182
+ // hides the DARK state the kickoff needs to explain, so render it with the lens forced instead.
183
+ securityPass = true;
184
+ trigger =
185
+ `could not look: GitHub CLI could not read PR #${pr}'s changed files — install GitHub CLI ` +
186
+ '(https://cli.github.com), then `gh auth login`. Security trigger is UNKNOWN; lens requested conservatively.';
187
+ } else {
188
+ const decision = decideSecurityPass({
189
+ files: facts.files,
190
+ body: facts.body,
191
+ securityPaths: config.securityPaths,
192
+ totalFiles: changedFileCount({ pr, repo }),
193
+ });
194
+ securityPass = decision.run;
195
+ trigger = decision.reason;
196
+ }
197
+ }
198
+ // `hasCmd` says INSTALLED, not UNCAPPED — a quota-capped CLI is present and answers `--version`.
199
+ // There is no way to know a family is capped without spending a run on it, so the fallback is
200
+ // operator-driven and one command long: re-run with `--exclude <family>` and the next one is routed.
201
+ // (cross-review.mjs additionally self-heals the common case: a dead codex token falls to agy mid-run.)
202
+ const available = PREFERENCE.filter((f) => hasCmd(AGENT_BIN[AGENT_FLAG[f]]) && !excluded.includes(f));
203
+ const plan = planReview({ builder, available, securityPass });
204
+ if (tierIgnored) {
205
+ process.stderr.write(
206
+ '⚠ --tier is ignored: the risk tier no longer selects reviewers. The security lens is triggered by ' +
207
+ 'the changed paths (scripts/review-config.json) or a `risk: high` PR body — pass --security to force it.\n'
208
+ );
209
+ }
210
+ if (json) {
211
+ writeSync(
212
+ 1,
213
+ `${JSON.stringify({ pr, repo, trigger, reviewScope: config.reviewScope, ...plan }, null, 2)}\n`
214
+ );
215
+ return;
216
+ }
217
+ writeSync(
218
+ 1,
219
+ `${renderPlan(plan, pr, repo)}\n\n security trigger: ${trigger}\n review scope: ${config.reviewScope}\n`
220
+ );
221
+ }
222
+
223
+ // realpath on both sides: through a symlinked path a plain compare is false and the script exits 0 having
224
+ // done nothing (#189 review).
225
+ const isMain = (() => {
226
+ try {
227
+ return (
228
+ !!process.argv[1] && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))
229
+ );
230
+ } catch {
231
+ return false;
232
+ }
233
+ })();
234
+ if (isMain) main();