@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.
@@ -0,0 +1,478 @@
1
+ // review-guard.mjs — the three small decisions the one-external-pass review stack rests on.
2
+ //
3
+ // Byte-identical in dobby-foundation's template and every consuming project (ways-of-work-lean-pass
4
+ // D11). Project-specific values live in `scripts/review-config.json`, never in this file.
5
+ //
6
+ // ── Since jev-semantic-guards (2026-09-23): Jev DECIDES "is this a real review?"; the regex below is the
7
+ // OFFLINE FALLBACK ── `judgeReviewOutput` (section 4) is what cross-review calls. `assertReviewOutput` still
8
+ // decides, byte-unchanged, whenever Jev cannot look (no key, egress:false, 429, timeout) or is unsure, and
9
+ // always when jev.config.json → rails.review.mode is `off`. Measured on 76 labelled replies: the judge 100%,
10
+ // this regex alone 86.8% — it rejected real prose findings and accepted plan transcripts (sprint-5.md).
11
+ //
12
+ // ── 1. A silent reviewer is a FAILED run (D9) ──────────────────────────────────────────────────────
13
+ // With two external passes, a CLI that exited 0 and printed nothing was contradicted by the other one.
14
+ // With one, nothing contradicts it: an empty or structureless reply reads exactly like "looks clean".
15
+ // That has already happened here — agy 1.0.10 silently changed its print contract and shipped empty
16
+ // reviews for weeks. So a reply must carry STRUCTURE: a severity heading, or an explicit clean verdict.
17
+ // Anything else fails the run, fails the PR's `cross-review/<lens>` status, and exits non-zero.
18
+ // Kept deliberately permissive about wording — a guard that rejects a real review trains people to
19
+ // bypass it — and strict only about "is there a review in here at all". Widened 2026-09-16 after a
20
+ // sweep of 593 machine-posted reviews found it would have rejected genuine ones: a bulleted or bold
21
+ // severity marker (`- **Blocking**: …`), a `**Findings**` heading, a `**Correctness & architecture**`
22
+ // prose review, and a bare `**None**` all count. The caller ALSO prints the full rejected reply, so a
23
+ // false reject can never destroy a review that cost a real run.
24
+ //
25
+ // ── 2. The security pass is triggered by PATHS, not by the builder's judgement (D7) ────────────────
26
+ // A PR gets the lean security lens when a changed file matches the project's `securityPaths` globs, or
27
+ // its body declares `risk: high`. The builder can always add the lens by hand; it cannot skip it.
28
+ //
29
+ // ── 3. The CLI version is RECORDED, not a hard stop ────────────────────────────────────────────────
30
+ // The output guard above is what catches a CLI whose print contract changed. A hard version pin on top
31
+ // would take the only external pass offline on every routine CLI update — the "one busy model took a
32
+ // whole family offline" failure this repo already recorded for agy. So the version lands in the PR
33
+ // comment and status, with a loud note when it differs from the last version verified to produce real
34
+ // reviews. (agy keeps its own hard pin in cross-agent-cli.mjs: its print contract has broken twice.)
35
+ //
36
+ // Zero deps — Node 18+.
37
+
38
+ import { spawnSync } from 'node:child_process';
39
+ import { jevContext } from './jev.mjs';
40
+
41
+ /** Codex CLI version last observed producing a real, structured review. Bump after a verified run. */
42
+ export const CODEX_VERIFIED = '0.154.0';
43
+
44
+ const SEVERITY_HEADING =
45
+ /^\s{0,3}(?:[-*+]\s+|\d+\.\s+|>\s*|\|\s*)?(?:#{1,6}\s*)?(?:\*\*|__)?\s*(?:🔴|🟠|🟡|⚪)?\s*(?:blocking|should[- ]fix|nits?|important|critical|findings?|correctness(?:\s*(?:&|and)\s*architecture)?|security(?: review)? findings?)\b/im;
46
+ /^\s{0,3}(?:#{1,6}\s*)?(?:\*\*|__)?\s*(?:🔴|🟠|🟡|⚪)?\s*(?:blocking|should[- ]fix|nits?|important|critical)\b/im;
47
+ const CLEAN_VERDICT =
48
+ /(?:^\s{0,3}(?:[-*+]\s+)?(?:#{1,6}\s*)?(?:\*\*|__)?\s*(?:clean|none)\b|\bassessment:\s*(?:\*\*)?\s*clean\b|\bno\s+(?:\*\*)?(?:blocking|security|should[- ]fix|nit)\b[^.\n]{0,80}\b(?:findings|issues|problems)\b|\bno (?:findings|issues|problems)\b|\bnothing (?:to report|found|blocking)\b|\blooks clean\b|\bdiff (?:is|looks) clean\b|\bno concerns\b)/im;
49
+ /(?:^\s{0,3}(?:#{1,6}\s*)?(?:\*\*|__)?\s*clean\b|\bassessment:\s*(?:\*\*)?\s*clean\b|\bno (?:blocking |security )?(?:findings|issues|problems)\b|\bnothing (?:to report|found|blocking)\b|\blooks clean\b|\bdiff (?:is|looks) clean\b|\bno concerns\b)/im;
50
+ // A reviewer that emitted a raw tool call instead of a review — observed twice from vibe on real PRs
51
+ // (`read_file{"path": …}`, `write_file…{"file_path": …}`). Its JSON can CONTAIN review-shaped words, so
52
+ // this is checked first and wins.
53
+ const TOOL_TRANSCRIPT = /^\s*[a-z_]+\S*\s*\{\s*"/i;
54
+
55
+ /**
56
+ * THE GUARD. Pure. Returns { ok, reason }.
57
+ * ok when the reply has a severity heading or an explicit clean verdict; fails on empty, whitespace,
58
+ * or prose with neither (a CLI banner, an error page, a truncated tool transcript).
59
+ */
60
+ export function assertReviewOutput(text) {
61
+ const t = String(text ?? '').trim();
62
+ if (!t) return { ok: false, reason: 'the reviewer returned no output' };
63
+ if (TOOL_TRANSCRIPT.test(t))
64
+ return { ok: false, reason: `the reviewer emitted a raw tool call, not a review ("${t.slice(0, 80)}")` };
65
+ if (SEVERITY_HEADING.test(t)) return { ok: true, reason: 'severity-structured findings' };
66
+ if (CLEAN_VERDICT.test(t)) return { ok: true, reason: 'explicit clean verdict' };
67
+ return {
68
+ ok: false,
69
+ reason: `the reviewer's reply has no severity heading and no clean verdict — not a review (first line: "${t.split('\n')[0].slice(0, 120)}")`,
70
+ };
71
+ }
72
+
73
+ /** Minimal glob → RegExp: `**` any depth, `*` within a segment, `?` one char. Anchored, case-sensitive. */
74
+ export function globToRegExp(glob) {
75
+ let re = '';
76
+ for (let i = 0; i < glob.length; i++) {
77
+ const c = glob[i];
78
+ if (c === '*' && glob[i + 1] === '*') {
79
+ re += glob[i + 2] === '/' ? '(?:.*/)?' : '.*';
80
+ i += glob[i + 2] === '/' ? 2 : 1;
81
+ } else if (c === '*') re += '[^/]*';
82
+ else if (c === '?') re += '[^/]';
83
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
84
+ }
85
+ return new RegExp(`^${re}$`);
86
+ }
87
+
88
+ /**
89
+ * Does this PR get the security lens? Pure.
90
+ * files: [{ path }] or strings · body: PR body · securityPaths: globs from review-config.json.
91
+ */
92
+ /** `gh pr view --json files` returns at most this many files, silently. */
93
+ export const GRAPHQL_FILE_CAP = 100;
94
+
95
+ /**
96
+ * The files that DEFINE the security trigger always trigger it, whatever `securityPaths` says. The list is read from
97
+ * the reviewer's checkout, which may be the PR's own branch: a PR that narrows `review.securityPaths` in
98
+ * golden-frijoles.config.json (or scripts/review-config.json) must get the lens on exactly that change, or it could
99
+ * switch the lens off for itself (security lens on #49).
100
+ */
101
+ export const ALWAYS_SECURITY_PATHS = Object.freeze([
102
+ 'golden-frijoles.config.json',
103
+ 'scripts/review-config.json',
104
+ // The code that reads that config and makes the decision is the trigger too: a PR that edits the loader or the
105
+ // router could make the settings say anything (security lens on #49, round 4).
106
+ 'scripts/lib/config.mjs',
107
+ 'scripts/lib/review-guard.mjs',
108
+ 'scripts/review-route.mjs',
109
+ 'scripts/cross-review.mjs',
110
+ ]);
111
+
112
+ export function decideSecurityPass({ files = [], body = '', securityPaths = [], totalFiles = null }) {
113
+ const paths = files.map((f) => (typeof f === 'string' ? f : f.path)).filter(Boolean);
114
+ const res = [...ALWAYS_SECURITY_PATHS, ...securityPaths].map(globToRegExp);
115
+ const matched = paths.filter((p) => res.some((r) => r.test(p)));
116
+ if (matched.length)
117
+ return {
118
+ run: true,
119
+ reason: `touches security paths: ${matched.slice(0, 5).join(', ')}${matched.length > 5 ? ` (+${matched.length - 5})` : ''}`,
120
+ matched,
121
+ };
122
+ // Requires the punctuation or the word "tier": plain prose like "a low-risk, high-value change" is not
123
+ // a declaration.
124
+ if (/\brisk\s*tier\s*[:=]\s*\**\s*high\b|\brisk\s*[:=]\s*\**\s*high\b/i.test(body))
125
+ return { run: true, reason: 'PR body declares risk: high', matched: [] };
126
+ // `gh pr view --json files` silently caps at 100 files — measured on a 108-file PR. A security file
127
+ // sorted past #100 would read as "no security path touched": a confident false negative on exactly
128
+ // the large refactor that most needs the lens. An incomplete list is UNKNOWN, and unknown forces the
129
+ // lens ON rather than off.
130
+ if (Number.isInteger(totalFiles) && totalFiles > paths.length)
131
+ return {
132
+ run: true,
133
+ reason: `file list truncated (${paths.length} of ${totalFiles} changed files seen) — lens forced on`,
134
+ matched: [],
135
+ };
136
+ // The REST count could not be read AND the list is at the GraphQL cap: truncation cannot be ruled out,
137
+ // and an unreadable total must not FAIL OPEN. Found independently by codex on two PRs and by the
138
+ // security lens itself. Under the cap the list is complete, so a missing count changes nothing there.
139
+ if (totalFiles == null && paths.length >= GRAPHQL_FILE_CAP)
140
+ return {
141
+ run: true,
142
+ reason: `${paths.length} files listed (the gh cap) and the true count could not be read — lens forced on`,
143
+ matched: [],
144
+ };
145
+ return { run: false, reason: 'no security path touched and no risk: high declared', matched: [] };
146
+ }
147
+
148
+ /**
149
+ * Validate a parsed `scripts/review-config.json`. Pure; throws with a named problem rather than
150
+ * defaulting — a missing config silently meaning "review nothing" is the confident-empty failure.
151
+ */
152
+ export function parseReviewConfig(json) {
153
+ const scopes = ['every-pr', 'security-paths-only'];
154
+ if (!json || typeof json !== 'object') throw new Error('review-config.json: not an object');
155
+ if (!scopes.includes(json.reviewScope))
156
+ throw new Error(`review-config.json: reviewScope must be one of ${scopes.join(' | ')}`);
157
+ if (
158
+ !Array.isArray(json.securityPaths) ||
159
+ !json.securityPaths.length ||
160
+ json.securityPaths.some((g) => typeof g !== 'string' || !g)
161
+ ) {
162
+ throw new Error('review-config.json: securityPaths must be a non-empty array of globs');
163
+ }
164
+ return { reviewScope: json.reviewScope, securityPaths: json.securityPaths };
165
+ }
166
+
167
+ /**
168
+ * The PR's TRUE changed-file count, from the REST endpoint (the GraphQL-backed `gh pr view --json files`
169
+ * silently caps at 100). null when it cannot be read — never a guess; the trigger treats null as "no
170
+ * extra signal" and the caller has already failed loudly if it could not read the file list at all.
171
+ */
172
+ export function changedFileCount({ pr, repo = null }, deps = {}) {
173
+ const run = deps.spawn ?? spawnSync;
174
+ let fullRepo = repo;
175
+ if (!fullRepo) {
176
+ const view = run('gh', ['pr', 'view', String(pr), '--json', 'url'], { encoding: 'utf8' });
177
+ if (view.status !== 0) return null;
178
+ try {
179
+ fullRepo = new URL(JSON.parse(view.stdout).url).pathname.split('/').slice(1, 3).join('/');
180
+ } catch {
181
+ return null;
182
+ }
183
+ }
184
+ const r = run('gh', ['api', `repos/${fullRepo}/pulls/${pr}`, '--jq', '.changed_files'], {
185
+ encoding: 'utf8',
186
+ });
187
+ if (r.status !== 0) return null;
188
+ const n = Number(String(r.stdout).trim());
189
+ return Number.isInteger(n) && n >= 0 ? n : null;
190
+ }
191
+
192
+ /** The `gh api` argv that sets a commit status. Pure, so the shape is pinned by a test. */
193
+ export function commitStatusArgs({ repo, sha, state, lens, description }) {
194
+ const context = `cross-review/${lens || 'general'}`;
195
+ return [
196
+ 'api',
197
+ '--method',
198
+ 'POST',
199
+ `repos/${repo}/statuses/${sha}`,
200
+ '-f',
201
+ `state=${state}`,
202
+ '-f',
203
+ `context=${context}`,
204
+ '-f',
205
+ `description=${String(description).slice(0, 140)}`,
206
+ ];
207
+ }
208
+
209
+ /**
210
+ * Post the status for a PR head. I/O shell with injectable spawn. Returns { posted, detail } and never
211
+ * throws: a status that could not be posted is reported by the caller, it does not mask the verdict.
212
+ */
213
+ export function postReviewStatus({ pr, repo, state, lens, description, sha = null }, deps = {}) {
214
+ const run = deps.spawn ?? spawnSync;
215
+ let head = sha;
216
+ let fullRepo = repo;
217
+ if (!head || !fullRepo) {
218
+ const view = run(
219
+ 'gh',
220
+ ['pr', 'view', String(pr), '--json', 'headRefOid,url', ...(repo ? ['--repo', repo] : [])],
221
+ { encoding: 'utf8' }
222
+ );
223
+ if (view.status !== 0)
224
+ return {
225
+ posted: false,
226
+ detail: `could not resolve PR #${pr} head: ${(view.stderr || '').trim().split('\n')[0]}`,
227
+ };
228
+ try {
229
+ const j = JSON.parse(view.stdout);
230
+ head = head || j.headRefOid;
231
+ if (!fullRepo) fullRepo = new URL(j.url).pathname.split('/').slice(1, 3).join('/');
232
+ } catch {
233
+ return { posted: false, detail: 'unparseable gh pr view output' };
234
+ }
235
+ }
236
+ // A partial `gh pr view` payload used to reach `head.slice(...)` and throw out of a function whose
237
+ // contract is that it never throws — masking the review verdict behind a stack trace.
238
+ if (typeof head !== 'string' || head.length < 7)
239
+ return { posted: false, detail: `no usable head sha for PR #${pr}` };
240
+ if (!fullRepo) return { posted: false, detail: `no repo for PR #${pr}` };
241
+ const r = run('gh', commitStatusArgs({ repo: fullRepo, sha: head, state, lens, description }), {
242
+ encoding: 'utf8',
243
+ });
244
+ if (r.status !== 0)
245
+ return { posted: false, detail: `gh api statuses failed: ${(r.stderr || '').trim().split('\n')[0]}` };
246
+ return { posted: true, detail: `${state} on ${fullRepo}@${head.slice(0, 7)}` };
247
+ }
248
+
249
+ /** The reviewer CLI's version, for the record. Three states: a version, 'unknown', never a guess. */
250
+ export function cliVersionNote(agent, deps = {}) {
251
+ const run = deps.spawn ?? spawnSync;
252
+ const bin = { codex: 'codex', antigravity: 'agy', vibe: 'vibe', claude: 'claude' }[agent] || agent;
253
+ const r = run(bin, ['--version'], { encoding: 'utf8' });
254
+ const m = /\d+\.\d+\.\d+/.exec(`${r.stdout || ''}${r.stderr || ''}`);
255
+ if (!m) return `${bin} version unknown`;
256
+ if (agent === 'codex' && m[0] !== CODEX_VERIFIED) {
257
+ return `${bin} ${m[0]} — ⚠ NOT the last verified version (${CODEX_VERIFIED}); the output guard still applied. Bump CODEX_VERIFIED in scripts/lib/review-guard.mjs after a clean run.`;
258
+ }
259
+ return `${bin} ${m[0]}`;
260
+ }
261
+
262
+ /** The hidden marker every review comment carries, so a later run knows WHAT was reviewed. */
263
+ export function reviewMarker({ lens = null, sha = null } = {}) {
264
+ return `\n<!-- cross-review lens=${lens || 'general'} sha=${sha || 'unknown'} -->`;
265
+ }
266
+
267
+ /**
268
+ * Re-review convergence (D8): Important-findings-only when this lens has ALREADY reviewed a DIFFERENT
269
+ * commit. A retry on the SAME commit — the first run died posting its status, say — is not a re-review,
270
+ * and suppressing its Should-fix and nits would silently shrink the only pass that PR ever got.
271
+ * A comment with no marker predates them and is treated as a re-review: the conservative side.
272
+ */
273
+ export function isReReview(commentBodies = [], lens = null, headSha = null) {
274
+ const title =
275
+ lens === 'security' ? /^### 🔐 Cross-agent review — security lens/m : /^### 🔎 Cross-agent review \(/m;
276
+ const wanted = lens || 'general';
277
+ return commentBodies.some((body) => {
278
+ const b = String(body);
279
+ if (!title.test(b)) return false;
280
+ const m = /<!-- cross-review lens=(\S+) sha=(\S+) -->/.exec(b);
281
+ if (!m) return true;
282
+ if (m[1] !== wanted) return false;
283
+ return !headSha || m[2] !== headSha;
284
+ });
285
+ }
286
+
287
+ export const RE_REVIEW_NOTE =
288
+ '\n\n## RE-REVIEW\nA previous pass already reviewed this PR and the author has pushed changes since. Report **Blocking and Should-fix findings only** — no nits, and do not repeat a finding the author has already fixed or answered on the PR.\n';
289
+
290
+ // ── 4. Jev decides the SEMANTIC question (jev-semantic-guards D4/D5) ────────────────────────────────
291
+ // `assertReviewOutput` above is a regex asking a language question — "did the reviewer actually review?" —
292
+ // and on 2026-09-19 it answered it wrong in both directions: it REJECTED a real finding written as plain
293
+ // prose, and ACCEPTED `## Findings` followed by "(reviewer timed out before completing analysis)". Jev
294
+ // (TypeSafe's calibrated typed-judgement model) got both right. So the judge below asks Jev, by
295
+ // `jev.config.json → rails.review.mode`:
296
+ // off → exactly `assertReviewOutput`; no call, no log line. The kill-switch.
297
+ // shadow → the regex decides; Jev is asked and BOTH verdicts are logged (.jev/decisions.jsonl + marker).
298
+ // jev → Jev decides at noul ≥ thresholds.real / ≤ thresholds.notReal; the regex decides in the band
299
+ // between ("uncertain") or when Jev could not look (no key, 429, timeout…). Never a guess.
300
+ // Two SHAPES stay mechanical and are decided before Jev is asked: an empty reply and a raw tool-call
301
+ // transcript. Those are not language judgements, and a model has nothing to add to them.
302
+ // `reason` always names who decided, so a regex fallback can never be read as a Jev verdict.
303
+
304
+ /** Reviewer replies are truncated to this for Jev (the 32k-token state budget), with a note. */
305
+ export const REVIEW_STATE_CHARS = 60_000;
306
+
307
+ export const REVIEW_QUESTIONS = {
308
+ // Wording MEASURED, not guessed — on the 76 labelled review fixtures in jev-eval.fixtures.json (2026-09-23,
309
+ // jev-1.13.0), at real ≥ 0.85 / not-real ≤ 0.15. "Is this a genuine review?" scored a real prose finding
310
+ // 0.73. "Does it contain an assessment of a code change?" decided only 38/76, because terse clean verdicts
311
+ // ("Clean.", "Blocking: None.") sat in the uncertain band. This wording, which names the terse verdicts
312
+ // and the failure shapes, decided 74/76 and got all 74 right (the regex alone: 66/76). Re-measure with
313
+ // `node scripts/jev-eval.mjs --live` before changing a word of it.
314
+ is_real_review: {
315
+ type: 'noul',
316
+ instructions:
317
+ 'This is the output of an automated code reviewer. Did the reviewer deliver a verdict on the change?',
318
+ criteria: {
319
+ true: 'Yes: it reports at least one finding about the code, OR it states a verdict that there is nothing to fix — e.g. "Clean.", "No findings.", "Blocking: None. Should-fix: None.", "Diff looks clean." A short clean verdict counts, and boilerplate footers after it do not change that.',
320
+ false:
321
+ 'No: the reviewer did not finish or never started — empty severity headings, a timeout or "analysis incomplete" note, a quota, rate-limit, login or HTTP error, a CLI banner or help text, a preamble or plan saying what it will do, or a raw tool-call transcript.',
322
+ },
323
+ },
324
+ severity: {
325
+ type: 'choice',
326
+ instructions: 'What is the most severe finding this review reports?',
327
+ criteria: {
328
+ blocking:
329
+ 'At least one finding the reviewer says must be fixed before merge (a bug, a broken contract, a security hole).',
330
+ should_fix: 'Nothing blocking, but at least one finding the reviewer says should be fixed.',
331
+ nit: 'Only minor or stylistic remarks.',
332
+ clean: 'No findings: the reviewer says the change is clean, or reports nothing to fix.',
333
+ },
334
+ },
335
+ };
336
+
337
+ /**
338
+ * The state Jev sees: the reply, or — when it would not fit — its HEAD and TAIL with a note between. The tail
339
+ * matters most: a reviewer states its verdict last, and a head-only cut of a 124k-character reply showed Jev
340
+ * nothing but a file-list preamble (2026-09-23 backtest). Pure.
341
+ */
342
+ export function reviewState(text) {
343
+ const t = String(text ?? '');
344
+ if (t.length <= REVIEW_STATE_CHARS) return t;
345
+ const head = Math.floor(REVIEW_STATE_CHARS / 3);
346
+ const tail = REVIEW_STATE_CHARS - head;
347
+ return `${t.slice(0, head)}\n\n[… ${t.length - REVIEW_STATE_CHARS} characters omitted from the middle for length …]\n\n${t.slice(-tail)}`;
348
+ }
349
+
350
+ /**
351
+ * Pure: the decision, given the regex verdict, Jev's result and the rail config. Exported so the eval
352
+ * harness and the specs pin the policy without a network.
353
+ */
354
+ export function decideReview({ regex, jev, mode, thresholds, error = null }) {
355
+ const base = { mode, regexOk: regex.ok, jev: jev ?? null, error };
356
+ if (mode === 'off') return { ...base, ok: regex.ok, decider: 'regex', reason: regex.reason };
357
+ if (mode === 'shadow')
358
+ return {
359
+ ...base,
360
+ ok: regex.ok,
361
+ decider: 'regex',
362
+ reason: `${regex.reason} — decided by regex (shadow${jev ? `; jev ${jev.noul.toFixed(2)}` : `; jev could not look (${error})`})`,
363
+ };
364
+ if (!jev)
365
+ return {
366
+ ...base,
367
+ ok: regex.ok,
368
+ decider: 'regex',
369
+ reason: `${regex.reason} — decided by regex: jev could not look (${error})`,
370
+ };
371
+ if (jev.noul >= thresholds.real)
372
+ return {
373
+ ...base,
374
+ ok: true,
375
+ decider: 'jev',
376
+ reason: `a real review (${jev.severity}) — decided by jev (${jev.noul.toFixed(2)})`,
377
+ };
378
+ if (jev.noul <= thresholds.notReal)
379
+ return {
380
+ ...base,
381
+ ok: false,
382
+ decider: 'jev',
383
+ reason: `the reviewer's reply is not a review — decided by jev (${jev.noul.toFixed(2)}); first line: "${String(regex.firstLine ?? '').slice(0, 120)}"`,
384
+ };
385
+ return {
386
+ ...base,
387
+ ok: regex.ok,
388
+ decider: 'regex',
389
+ reason: `${regex.reason} — decided by regex: jev uncertain (${jev.noul.toFixed(2)})`,
390
+ };
391
+ }
392
+
393
+ /**
394
+ * THE JUDGE. async; never throws on a Jev failure (a malformed jev.config.json DOES throw — loudly).
395
+ * Returns { ok, reason, decider, mode, regexOk, jev: {noul, severity, model} | null, error }.
396
+ * opts: { sha, source } for the log. deps: see jevContext (config, key, ask, log, root).
397
+ */
398
+ export async function judgeReviewOutput(text, opts = {}, deps = {}) {
399
+ const t = String(text ?? '').trim();
400
+ const regex = { ...assertReviewOutput(t), firstLine: t.split('\n')[0] };
401
+ const ctx = jevContext('review', deps);
402
+ const mechanical = !t || TOOL_TRANSCRIPT.test(t);
403
+ if (ctx.mode === 'off' || mechanical) {
404
+ const d = decideReview({ regex, jev: null, mode: 'off', thresholds: ctx.rail.thresholds });
405
+ // Configured for Jev but it cannot be asked (no key, egress:false): say so, so the fallback never reads
406
+ // like the configured path. The kill-switch itself (`mode: off`) stays exactly assertReviewOutput.
407
+ const degraded = !mechanical && ctx.configured !== 'off';
408
+ return {
409
+ ...d,
410
+ mode: ctx.mode,
411
+ why: mechanical ? 'mechanical shape' : ctx.why,
412
+ ...(degraded ? { reason: `${d.reason} — decided by regex: jev could not look (${ctx.why})` } : {}),
413
+ };
414
+ }
415
+ const res = await ctx.ask({ state: reviewState(t), questions: REVIEW_QUESTIONS });
416
+ // Only a real probability is a verdict. `Number()` would turn `true`, "1" or 5 into a Jev-decided PASS and
417
+ // null or "" into a FAIL (fresh review, PR #35) — any other shape is could-not-look and the regex decides.
418
+ const raw = res.ok ? res.answers?.is_real_review?.noul : undefined;
419
+ const valid = typeof raw === 'number' && raw >= 0 && raw <= 1;
420
+ const jev = res.ok
421
+ ? { noul: raw, severity: res.answers?.severity?.choice ?? null, model: res.model }
422
+ : null;
423
+ const d = decideReview({
424
+ regex,
425
+ jev: valid ? jev : null,
426
+ mode: ctx.mode,
427
+ thresholds: ctx.rail.thresholds,
428
+ error: res.ok ? (valid ? null : `invalid noul (${JSON.stringify(raw)})`) : res.error,
429
+ });
430
+ ctx.log({
431
+ rail: 'review',
432
+ mode: d.mode,
433
+ decider: d.decider,
434
+ regex: regex.ok,
435
+ jev: d.jev ? d.jev.noul >= 0.5 : null,
436
+ confidence: d.jev ? d.jev.noul : null,
437
+ text: t,
438
+ sha: opts.sha ?? null,
439
+ source: opts.source ?? null,
440
+ error: d.error,
441
+ });
442
+ return d;
443
+ }
444
+
445
+ /**
446
+ * The hidden marker a posted cross-review comment carries, so a verdict made in a cloud routine (whose
447
+ * .jev/ log dies with the session) can be harvested later with `gh api`. Never carries the reply text.
448
+ */
449
+ export function jevMarker(verdict) {
450
+ // `model` comes from the API response; a `-->` in it would close the comment early (fresh review, PR #35).
451
+ const payload = {
452
+ mode: verdict?.mode ?? 'off',
453
+ decider: verdict?.decider ?? 'regex',
454
+ // The regex's OWN verdict. Once Jev decides, a posted comment is no longer proof the regex accepted the
455
+ // reply — without this, the report would count Jev-only passes as agreement (review of PR #39).
456
+ regexOk: typeof verdict?.regexOk === 'boolean' ? verdict.regexOk : null,
457
+ noul: typeof verdict?.jev?.noul === 'number' ? Number(verdict.jev.noul.toFixed(3)) : null,
458
+ severity: verdict?.jev?.severity ?? null,
459
+ model: verdict?.jev?.model ?? null,
460
+ };
461
+ return `\n<!-- jev:${JSON.stringify(payload).replace(/--/g, '-\\u002d')} -->`;
462
+ }
463
+
464
+ /**
465
+ * Parse a jev marker back out of a comment body (the S5 report's harvest). null when absent. The LAST marker
466
+ * wins: the reviewer's reply comes before the real one, and a reply shaped by the diff could carry a forged
467
+ * marker (fresh review, PR #35).
468
+ */
469
+ export function parseJevMarker(body) {
470
+ const all = [...String(body ?? '').matchAll(/<!-- jev:(\{.*?\}) -->/g)];
471
+ const m = all[all.length - 1];
472
+ if (!m) return null;
473
+ try {
474
+ return JSON.parse(m[1]);
475
+ } catch {
476
+ return null;
477
+ }
478
+ }