codereview-loop 0.13.0__py3-none-any.whl

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.
codereview/cli.py ADDED
@@ -0,0 +1,2927 @@
1
+ #!/usr/bin/env python3
2
+ """codereview CLI.
3
+
4
+ Examples:
5
+ codereview --repo . --staged --provider gemini --provider openai
6
+ codereview --patch change.diff --provider xai --provider gemini \
7
+ --test-command "python -m pytest -q"
8
+ codereview --patch fix.diff --verify-previous report.json \
9
+ --builder-response response.md --provider gemini --provider nvidia
10
+ codereview loop --repo . --builder-cmd "your-coding-agent" \
11
+ --provider gemini --provider gemini --provider nvidia
12
+ codereview workflow --file workflow.json --repo . --out wf-out
13
+ codereview pr --repo OWNER/REPO --number 123 --provider gemini
14
+ codereview pr --pr https://github.com/OWNER/REPO/pull/123 --post
15
+ codereview intake issue-report.md --provider gemini \
16
+ --provider nvidia --context src/alerts.py
17
+ codereview sarif review-out --out results.sarif
18
+ codereview policy check # validate .codereview.toml and
19
+ # print the effective project policy
20
+ codereview evidence --repo . --config evidence.json
21
+ codereview evidence --repo . --claims builder-report.md
22
+
23
+ Exit codes: 0 = gate PASS (intake: a solution was produced),
24
+ 1 = gate BLOCKED, 2 = INCONCLUSIVE or usage/infrastructure error
25
+ (intake: every solver failed).
26
+ """
27
+
28
+ from __future__ import annotations
29
+
30
+ import argparse
31
+ import hashlib
32
+ import json
33
+ import os
34
+ import subprocess
35
+ import sys
36
+ import tempfile
37
+
38
+ import inspect
39
+
40
+ from . import agentsmd
41
+ from . import bench as bench_mod
42
+ from . import claims as claims_mod
43
+ from . import core
44
+ from . import crash as crash_mod
45
+ from . import disputes as disputes_mod
46
+ from . import hardening
47
+ from . import herdrhost
48
+ from . import hooks as hooks_mod
49
+ from . import notify as notify_mod
50
+ from . import policy as policy_mod
51
+ from . import evidence as evidence_mod
52
+ from . import registry
53
+ from . import sarif as sarif_mod
54
+ from . import setup_wizard, userconfig
55
+ from .providers import (ProviderError, build_providers,
56
+ describe_spec)
57
+
58
+
59
+ def _run_tests(commands, cwd):
60
+ # shell=True is deliberate: test commands are operator-supplied
61
+ # shell strings (the same trust level as a CI config the operator
62
+ # wrote). They are never taken from the diff under review.
63
+ results = []
64
+ for cmd in commands or []:
65
+ try:
66
+ out = subprocess.run(cmd, shell=True, cwd=cwd,
67
+ capture_output=True, timeout=1800,
68
+ check=False)
69
+ tail = (out.stdout + out.stderr).decode(
70
+ "utf-8", errors="replace")[-4000:]
71
+ results.append({"command": cmd, "ok": out.returncode == 0,
72
+ "exit_code": out.returncode,
73
+ "output_tail": tail})
74
+ except subprocess.TimeoutExpired:
75
+ results.append({"command": cmd, "ok": False,
76
+ "exit_code": None,
77
+ "output_tail": "timed out"})
78
+ return results
79
+
80
+
81
+ def _load_patch(args):
82
+ if args.patch:
83
+ with open(os.path.expanduser(args.patch), encoding="utf-8",
84
+ errors="replace") as fh:
85
+ return fh.read()
86
+ repo = args.repo or "."
87
+ if args.files:
88
+ return core.build_pseudo_patch(args.files, root=repo)
89
+ return core.collect_git_patch(repo, range_spec=args.range,
90
+ staged=args.staged)
91
+
92
+
93
+ # The shared containment helper lives in core (P3-11: the
94
+ # CLI loaders, core's file-context collector and the
95
+ # AGENTS.md collector use ONE implementation). This alias
96
+ # keeps the historical cli._is_within name working.
97
+ _is_within = core._is_within
98
+
99
+
100
+ def _load_fixture_scope(path, repo="."):
101
+ """Load a versioned fixture manifest (clean-gate solution §3):
102
+ returns the fixture_scope dict run_review records and
103
+ record-verify recomputes — manifest path (repo-relative),
104
+ content hash of the exact bytes, policy version, and the
105
+ enumerated fixture paths. The manifest declares which paths
106
+ are intentionally vulnerable test DATA; a review run only
107
+ classifies fixture findings when the operator passes one."""
108
+ repo_abs = os.path.realpath(repo)
109
+ full = os.path.realpath(
110
+ path if os.path.isabs(path)
111
+ else os.path.join(repo_abs, path))
112
+ if not _is_within(full, repo_abs):
113
+ # The recorded manifest path is replayed by record-verify
114
+ # as a repo-relative object name; a manifest outside the
115
+ # repo could neither be recorded honestly nor verified.
116
+ raise ValueError(
117
+ f"fixture manifest {path}: resolves outside the "
118
+ f"repository ({full})")
119
+ with open(full, "rb") as fh:
120
+ raw = fh.read()
121
+ manifest = json.loads(raw.decode("utf-8"))
122
+ if not isinstance(manifest, dict) \
123
+ or manifest.get("schema") != core.FIXTURE_MANIFEST_SCHEMA:
124
+ raise ValueError(
125
+ f"fixture manifest {path}: schema must be "
126
+ f"{core.FIXTURE_MANIFEST_SCHEMA!r}")
127
+ if manifest.get("policy_version") \
128
+ not in core.FIXTURE_POLICY_VERSIONS:
129
+ raise ValueError(
130
+ f"fixture manifest {path}: unsupported policy_version "
131
+ f"{manifest.get('policy_version')!r} (supported: "
132
+ f"{core.FIXTURE_POLICY_VERSIONS})")
133
+ entries = manifest.get("fixtures")
134
+ if not isinstance(entries, list) or not entries or any(
135
+ not isinstance(e, dict)
136
+ or not str(e.get("path") or "").strip()
137
+ for e in entries):
138
+ raise ValueError(
139
+ f"fixture manifest {path}: 'fixtures' must be a "
140
+ f"non-empty list of objects each naming a 'path'")
141
+ patterns = [str(e["path"]) for e in entries]
142
+ # Optional per-path expected finding counts (v0.9.3, §2f):
143
+ # when an entry declares one, findings on that path beyond
144
+ # the count are NOT classified as expected — they stay in
145
+ # the record as product findings and the excess is noted.
146
+ # Absent = no count expectation (the policy-v1 behaviour).
147
+ expected_counts = {}
148
+ for e in entries:
149
+ if "expected_finding_count" in e:
150
+ v = e.get("expected_finding_count")
151
+ if not isinstance(v, int) or isinstance(v, bool) or v < 0:
152
+ raise ValueError(
153
+ f"fixture manifest {path}: entry "
154
+ f"{e.get('path')!r} has a bad "
155
+ f"expected_finding_count {v!r} (want a "
156
+ f"non-negative integer)")
157
+ expected_counts[str(e["path"])] = v
158
+ return {
159
+ "manifest_path": os.path.relpath(full, repo_abs).replace(
160
+ os.sep, "/"),
161
+ "manifest_sha256": hashlib.sha256(raw).hexdigest(),
162
+ "policy_version": manifest.get("policy_version"),
163
+ "patterns": patterns,
164
+ "expected_counts": expected_counts,
165
+ }
166
+
167
+
168
+ def _load_precedents(path, repo="."):
169
+ """Load the content-addressed precedent store (v0.9.3, §2e):
170
+ returns the precedents dict run_review consumes — entries,
171
+ a blob_lookup resolving a cited path's CURRENT git blob id
172
+ at HEAD, a source_lookup resolving its CURRENT text at HEAD
173
+ (for function-level entries, §5d), and the store's
174
+ repo-relative path + content hash for the record
175
+ (record-verify recomputes both, exactly like the fixture
176
+ manifest). The store declares findings already adjudicated;
177
+ classification additionally requires the cited file's blob
178
+ to be unchanged, so a stale store entry is inert, never
179
+ wrong."""
180
+ repo_abs = os.path.realpath(repo)
181
+ full = os.path.realpath(
182
+ path if os.path.isabs(path)
183
+ else os.path.join(repo_abs, path))
184
+ if not _is_within(full, repo_abs):
185
+ raise ValueError(
186
+ f"precedent store {path}: resolves outside the "
187
+ f"repository ({full})")
188
+ with open(full, "rb") as fh:
189
+ raw = fh.read()
190
+ store = json.loads(raw.decode("utf-8"))
191
+ if not isinstance(store, dict) \
192
+ or store.get("schema") != core.PRECEDENTS_SCHEMA:
193
+ raise ValueError(
194
+ f"precedent store {path}: schema must be "
195
+ f"{core.PRECEDENTS_SCHEMA!r}")
196
+ entries = store.get("entries")
197
+ if not isinstance(entries, list) or any(
198
+ not isinstance(e, dict)
199
+ or not str(e.get("finding_id") or "").strip()
200
+ or not str(e.get("path") or "").strip()
201
+ or not (str(e.get("blob") or "").strip()
202
+ or (str(e.get("function") or "").strip()
203
+ and str(e.get("function_hash")
204
+ or "").strip()))
205
+ for e in entries):
206
+ raise ValueError(
207
+ f"precedent store {path}: 'entries' must be a list "
208
+ f"of objects each naming finding_id, path and a "
209
+ f"content address (blob, or function + "
210
+ f"function_hash for a function-level entry, §5d)")
211
+
212
+ def blob_lookup(p):
213
+ out = subprocess.run(
214
+ ["git", "-C", repo_abs, "rev-parse", f"HEAD:{p}"],
215
+ capture_output=True, timeout=120, check=False)
216
+ if out.returncode != 0:
217
+ return None
218
+ return out.stdout.decode("utf-8", "replace").strip() or None
219
+
220
+ def source_lookup(p):
221
+ out = subprocess.run(
222
+ ["git", "-C", repo_abs, "show", f"HEAD:{p}"],
223
+ capture_output=True, timeout=120, check=False)
224
+ if out.returncode != 0:
225
+ return None
226
+ return out.stdout.decode("utf-8", "replace")
227
+
228
+ return {
229
+ "entries": entries,
230
+ "blob_lookup": blob_lookup,
231
+ "source_lookup": source_lookup,
232
+ "store_path": os.path.relpath(full, repo_abs).replace(
233
+ os.sep, "/"),
234
+ "store_sha256": hashlib.sha256(raw).hexdigest(),
235
+ }
236
+
237
+
238
+ def _stdin_is_tty():
239
+ try:
240
+ return bool(sys.stdin.isatty())
241
+ except Exception: # noqa: BLE001 — no stdin, closed stdin
242
+ return False
243
+
244
+
245
+ def _probe_writable(path):
246
+ """Prove `path` can be written into, or return the OSError
247
+ that says it cannot (P5-07b): create the directory chain
248
+ and a temp file for real, then remove them. The Phase 5
249
+ check inferred writability from os.access, which lies for
250
+ a root user on /proc and /sys — the full review ran and
251
+ the late write crashed, discarding the result. A real
252
+ create cannot lie: it fails for root exactly as it fails
253
+ for anyone else. The side effect is brief — the temp file
254
+ is unlinked and any directories the probe created are
255
+ removed again (deepest first, best effort)."""
256
+ missing = []
257
+ probe = path
258
+ while not os.path.exists(probe):
259
+ missing.append(probe)
260
+ probe = os.path.dirname(probe)
261
+ try:
262
+ os.makedirs(path, exist_ok=True)
263
+ fd, tmpname = tempfile.mkstemp(
264
+ dir=path, prefix=".codereview-probe-")
265
+ try:
266
+ os.close(fd)
267
+ finally:
268
+ os.unlink(tmpname)
269
+ except OSError as exc:
270
+ return exc
271
+ finally:
272
+ for created in missing: # deepest first (walk order)
273
+ try:
274
+ os.rmdir(created)
275
+ except OSError:
276
+ pass
277
+ return None
278
+
279
+
280
+ def _out_dir_problem(out):
281
+ """Why the --out directory cannot be written into, or None
282
+ (P5-07). The check runs at argument-validation time so an
283
+ unwritable --out fails BEFORE any provider call, instead
284
+ of after the review has spent them (when the late crash
285
+ also lost the result). Writability is PROVEN by
286
+ _probe_writable, not inferred from os.access (P5-07b);
287
+ os.access is still consulted first so a plainly unwritable
288
+ ancestor is reported without creating anything."""
289
+ if not out:
290
+ return None
291
+ path = os.path.abspath(os.path.expanduser(out))
292
+ probe = path
293
+ while not os.path.exists(probe):
294
+ parent = os.path.dirname(probe)
295
+ if parent == probe:
296
+ return None
297
+ probe = parent
298
+ if not os.path.isdir(probe):
299
+ return f"--out {out}: {probe} is not a directory"
300
+ if not os.access(probe, os.W_OK | os.X_OK):
301
+ if probe == path:
302
+ return f"--out {out}: directory is not writable"
303
+ return (f"--out {out}: cannot be created — "
304
+ f"{probe} is not writable")
305
+ exc = _probe_writable(path)
306
+ if exc is not None:
307
+ return (f"--out {out}: cannot be written "
308
+ f"({exc.strerror or exc})")
309
+ return None
310
+
311
+
312
+ def _refuse_unwritable_out(out, prog):
313
+ """Print the P5-07 refusal and return True when --out is
314
+ unusable, so the caller exits 2 before doing any work."""
315
+ problem = _out_dir_problem(out)
316
+ if problem:
317
+ print(f"{prog}: {problem}", file=sys.stderr)
318
+ return True
319
+ return False
320
+
321
+
322
+ class _ReportWriteFailed(Exception):
323
+ """The review completed but writing its report failed
324
+ (P5-07b): _write_report has already printed the verdict
325
+ and preserved the record — the caller just exits 2."""
326
+
327
+
328
+ def _write_report(report, out_dir, prog, stem=None):
329
+ """core.write_report with the P5-07b late-failure net.
330
+
331
+ The up-front --out probe cannot see a disk that fills, a
332
+ permission change or a mount that goes read-only DURING
333
+ the review. When the write then raises OSError, the
334
+ completed review must not be discarded with a traceback:
335
+ the verdict is printed to stdout, the record is written
336
+ to a fallback location (the current directory, then the
337
+ temp directory, then — last resort — stdout itself as
338
+ JSON), and _ReportWriteFailed is raised so the caller
339
+ exits 2. Non-OSError failures (e.g. record-schema
340
+ validation) are NOT caught: they are defects, not
341
+ environment failures, and a fallback write would fail
342
+ identically."""
343
+ try:
344
+ return core.write_report(report, out_dir, stem=stem)
345
+ except OSError as exc:
346
+ print(f"{prog}: report write to {out_dir} failed: "
347
+ f"{exc}", file=sys.stderr)
348
+ gate = str(report.get("gate") or "inconclusive").upper()
349
+ reasons = "; ".join(report.get("gate_reasons") or [])
350
+ print(f"{prog}: gate = {gate} ({reasons})")
351
+ saved = None
352
+ for fallback in (os.getcwd(), tempfile.gettempdir()):
353
+ try:
354
+ saved, _mpath = core.write_report(
355
+ report, fallback, stem=stem)
356
+ break
357
+ except OSError:
358
+ continue
359
+ if saved is not None:
360
+ print(f"{prog}: record saved to fallback: {saved}")
361
+ else:
362
+ print(f"{prog}: no fallback location was writable; "
363
+ f"the full record follows on stdout",
364
+ file=sys.stderr)
365
+ print(json.dumps(report, ensure_ascii=False,
366
+ indent=2, default=str))
367
+ raise _ReportWriteFailed(str(exc)) from exc
368
+
369
+
370
+ def _run_setup_wizard():
371
+ """The first-run wizard (test seam): the CLI wires the live
372
+ seat probe; tests substitute this to script a setup."""
373
+ return setup_wizard.run_wizard(
374
+ probe_fn=lambda providers: core.run_doctor(providers))
375
+
376
+
377
+ def _evidence_main(argv):
378
+ """codereview evidence — the Epic C evidence runner,
379
+ standalone: run a config's commands/adapters against a tree,
380
+ or verify the claimed commands in a builder's work report.
381
+ Exit codes mirror the gate: 0 = evidence stands (claims:
382
+ every claim confirmed), 1 = required evidence failed /
383
+ a claim was downgraded, 2 = inconclusive or usage error."""
384
+ ap = argparse.ArgumentParser(
385
+ prog="codereview evidence",
386
+ description="Run configured evidence (tests, lint, SAST, "
387
+ "security adapters) in a sandboxed copy of "
388
+ "the tree, or verify a builder report's "
389
+ "claimed commands by re-running them.")
390
+ ap.add_argument("--repo", default=".",
391
+ help="working tree to run evidence against "
392
+ "(default: .)")
393
+ ap.add_argument("--config",
394
+ help="evidence config JSON (commands + "
395
+ "adapters + policy-required names)")
396
+ ap.add_argument("--claims",
397
+ help="builder work report (markdown/text): "
398
+ "re-run its claimed commands and compare")
399
+ ap.add_argument("--out", default="codereview-out",
400
+ help="output directory for the JSON record "
401
+ "(default: codereview-out)")
402
+ args = ap.parse_args(argv)
403
+ if bool(args.config) == bool(args.claims):
404
+ print("codereview evidence: pass exactly one of --config "
405
+ "or --claims", file=sys.stderr)
406
+ return 2
407
+ os.makedirs(args.out, exist_ok=True)
408
+ if args.claims:
409
+ try:
410
+ with open(os.path.expanduser(args.claims),
411
+ encoding="utf-8", errors="replace") as fh:
412
+ text = fh.read()
413
+ except Exception as exc: # noqa: BLE001
414
+ print(f"codereview evidence: could not read the "
415
+ f"report: {exc}", file=sys.stderr)
416
+ return 2
417
+ result = evidence_mod.verify_claims(text, args.repo)
418
+ path = os.path.join(args.out, "claims.json")
419
+ with open(path, "w", encoding="utf-8") as fh:
420
+ json.dump(result, fh, ensure_ascii=False, indent=2)
421
+ fh.write("\n")
422
+ for claim in result["claims"]:
423
+ print(f" {claim['status']:<10} line "
424
+ f"{claim['line']}: {claim['command']}"
425
+ + (f" — {claim['detail']}"
426
+ if claim.get("detail") else ""))
427
+ summary = result["summary"]
428
+ print(f"codereview evidence: {summary['confirmed']} "
429
+ f"confirmed, {summary['downgraded']} downgraded, "
430
+ f"{summary['error']} error(s) of "
431
+ f"{summary['total']} claim(s)")
432
+ print(f" data: {path}")
433
+ if summary["error"]:
434
+ return 2
435
+ return 1 if summary["downgraded"] else 0
436
+ try:
437
+ config = evidence_mod.load_config(args.config)
438
+ except ValueError as exc:
439
+ print(f"codereview: {exc}", file=sys.stderr)
440
+ return 2
441
+ try:
442
+ record = evidence_mod.collect(config, args.repo)
443
+ except Exception as exc: # noqa: BLE001
444
+ # A collect failure (e.g. a mistyped config value that
445
+ # passes validation and blows up inside a subprocess
446
+ # call) is a subcommand error — exit 2 with the reason,
447
+ # never a traceback (934fab88a12e).
448
+ print(f"codereview evidence: collection failed: {exc}",
449
+ file=sys.stderr)
450
+ return 2
451
+ path = os.path.join(args.out, "evidence.json")
452
+ with open(path, "w", encoding="utf-8") as fh:
453
+ json.dump(record, fh, ensure_ascii=False, indent=2)
454
+ fh.write("\n")
455
+ for item in record["items"]:
456
+ print(f" {item['status']:<11} {item['name']}"
457
+ + (" (required)" if item["required"] else "")
458
+ + (f" — {item['detail']}" if item.get("detail")
459
+ else ""))
460
+ effect, reasons = evidence_mod.gate_effect(record["items"])
461
+ print(f"codereview evidence: gate effect = "
462
+ f"{(effect or 'none').upper()}"
463
+ + (f" ({'; '.join(reasons)})" if reasons else ""))
464
+ print(f" data: {path}")
465
+ return {"blocked": 1, "inconclusive": 2}.get(effect, 0)
466
+
467
+
468
+ def _setup_main(argv):
469
+ """codereview setup — first-run (or re-run) user setup:
470
+ pick providers, name the key env vars, assign per-role
471
+ models, save them as YOUR defaults. No panel is imposed."""
472
+ ap = argparse.ArgumentParser(
473
+ prog="codereview setup",
474
+ description="First-run setup: choose your providers, "
475
+ "keys, and per-role models, and save them "
476
+ "as your defaults (re-runnable to edit).")
477
+ ap.parse_args(argv)
478
+ try:
479
+ result = _run_setup_wizard()
480
+ except KeyboardInterrupt:
481
+ # P5-06: Ctrl-C is a cancellation, not a crash — no
482
+ # traceback. The wizard saves only at the very end, so
483
+ # nothing was written. 130 is the conventional
484
+ # SIGINT exit code.
485
+ print("codereview setup: cancelled — nothing saved.",
486
+ file=sys.stderr)
487
+ return 130
488
+ except EOFError:
489
+ # P5-13: the wizard already printed its abort line
490
+ # ("setup aborted — nothing saved.") at the prompt that
491
+ # hit EOF; re-printing the exception here duplicated
492
+ # the message.
493
+ return 2
494
+ except Exception as exc: # noqa: BLE001
495
+ print(f"codereview setup: {exc}", file=sys.stderr)
496
+ return 2
497
+ return 0 if result else 2
498
+
499
+
500
+ def _loop_main(argv, providers_override=None):
501
+ """codereview loop — drive review -> builder fix -> verify ->
502
+ advance until PASS or the builder-round budget runs out."""
503
+ ap = argparse.ArgumentParser(
504
+ prog="codereview loop",
505
+ description="Loop driver: reviewers review the working-tree "
506
+ "diff, a builder command (the coding agent) fixes "
507
+ "the findings and writes a work report, and a "
508
+ "verification round decides whether the workflow "
509
+ "advances.",
510
+ epilog="Surface note (P6-08): loop takes --provider (or the "
511
+ "environment / saved setup), --range/--staged, "
512
+ "--waive, --test-command, --allow-major and "
513
+ "--strict — and no --seat-meta, --policy or --role: "
514
+ "it drives its own rounds against the working tree "
515
+ "as the builder edits it, so the policy-driven "
516
+ "surfaces (tiering, hooks, gate profile, role "
517
+ "assignment) belong to the gate command. Its "
518
+ "per-round records carry no committed range/head "
519
+ "and are not Form B chain / release records; for a "
520
+ "release record, run the gate command over a "
521
+ "committed range.")
522
+ ap.add_argument("--repo", default=".",
523
+ help="git repository path (default: .)")
524
+ ap.add_argument("--range", dest="range",
525
+ help="git diff range (default: working tree vs "
526
+ "HEAD)")
527
+ ap.add_argument("--staged", action="store_true")
528
+ ap.add_argument("--builder-cmd", required=True,
529
+ help="command that acts as the coding agent. It "
530
+ "receives $CODEREVIEW_PACKET (JSON: gate + "
531
+ "open findings), edits the working tree, and "
532
+ "writes its work report to $CODEREVIEW_RESPONSE")
533
+ ap.add_argument("--max-rounds", type=int, default=3,
534
+ help="builder rounds before stopping for a human "
535
+ "(default 3)")
536
+ ap.add_argument("--waive", action="append", default=[],
537
+ help="finding ID waived by the owner (recorded "
538
+ "in the result); repeatable")
539
+ ap.add_argument("--provider", action="append", default=[],
540
+ help="provider preset or custom spec; repeatable")
541
+ ap.add_argument("--context", default="")
542
+ ap.add_argument("--test-command", action="append", default=[])
543
+ ap.add_argument("--out", default="codereview-out")
544
+ ap.add_argument("--allow-major", action="store_true")
545
+ ap.add_argument("--strict", action="store_true")
546
+ args = ap.parse_args(argv)
547
+
548
+ if _refuse_unwritable_out(args.out, "codereview loop"):
549
+ return 2
550
+ if providers_override is not None:
551
+ providers = providers_override
552
+ else:
553
+ specs = args.provider
554
+ if not specs:
555
+ env = (os.environ.get("CODEREVIEW_PROVIDERS") or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
556
+ try:
557
+ specs = [s.strip() for s in env.split(",")
558
+ if s.strip()] \
559
+ or userconfig.default_provider_specs()
560
+ except ValueError as exc:
561
+ print(f"codereview: {exc}", file=sys.stderr)
562
+ return 2
563
+ if not specs:
564
+ print(userconfig.NOT_SET_UP, file=sys.stderr)
565
+ return 2
566
+ try:
567
+ providers = build_providers(specs)
568
+ except Exception as exc: # noqa: BLE001
569
+ print(f"codereview: provider setup failed: {exc}",
570
+ file=sys.stderr)
571
+ return 2
572
+
573
+ os.makedirs(args.out, exist_ok=True)
574
+ packet_path = os.path.join(args.out, "builder-packet.json")
575
+ response_path = os.path.join(args.out, "builder-response.md")
576
+
577
+ def get_patch():
578
+ if not args.staged and not args.range:
579
+ core.include_untracked(args.repo)
580
+ return core.collect_git_patch(args.repo, range_spec=args.range,
581
+ staged=args.staged)
582
+
583
+ def invoke_builder(packet):
584
+ with open(packet_path, "w", encoding="utf-8") as fh:
585
+ json.dump(packet, fh, ensure_ascii=False, indent=1)
586
+ env = dict(os.environ, CODEREVIEW_PACKET=packet_path,
587
+ CODEREVIEW_RESPONSE=response_path,
588
+ CODEREVIEW_GATE=str(packet.get("gate") or ""),
589
+ CODEREVIEW_ROUND=str(packet.get("round") or ""))
590
+ # Legacy aliases (pre-v0.5.0 name) for existing builders.
591
+ for _k in [k for k in env if k.startswith("CODEREVIEW_")]:
592
+ env.setdefault("REVIEWLOOP_" + _k[len("CODEREVIEW_"):],
593
+ env[_k])
594
+ if os.path.exists(response_path):
595
+ os.remove(response_path)
596
+ out = subprocess.run(args.builder_cmd, shell=True, cwd=args.repo,
597
+ env=env, capture_output=True, timeout=3600,
598
+ check=False)
599
+ if os.path.exists(response_path):
600
+ with open(response_path, encoding="utf-8",
601
+ errors="replace") as fh:
602
+ return fh.read()
603
+ return (out.stdout + out.stderr).decode(
604
+ "utf-8", errors="replace")[-4000:]
605
+
606
+ def on_round(rnd, report):
607
+ _write_report(report, os.path.join(args.out,
608
+ f"round-{rnd}"),
609
+ "codereview loop")
610
+
611
+ try:
612
+ result = core.run_loop(
613
+ get_patch, invoke_builder, providers,
614
+ context=args.context,
615
+ test_runner=lambda: _run_tests(args.test_command,
616
+ args.repo),
617
+ max_rounds=args.max_rounds,
618
+ allow_major=args.allow_major,
619
+ strict=args.strict, on_round=on_round,
620
+ waived_ids=args.waive)
621
+ except _ReportWriteFailed:
622
+ return 2
623
+ summary = {k: v for k, v in result.items() if k != "rounds"}
624
+ summary["rounds"] = [{"round": r["round"], "kind": r["kind"],
625
+ "gate": r["gate"],
626
+ "files": r.get("files") or [],
627
+ "changes_vs_previous":
628
+ r.get("changes_vs_previous") or {}}
629
+ for r in result["rounds"]]
630
+ summary_path = os.path.join(args.out, "loop-result.json")
631
+ try:
632
+ with open(summary_path, "w", encoding="utf-8") as fh:
633
+ json.dump(summary, fh, ensure_ascii=False, indent=1)
634
+ except OSError as exc:
635
+ # P5-07b: the loop's outcome must survive a late write
636
+ # failure the same way a gate report does — print the
637
+ # final gate, park the summary in a fallback location,
638
+ # exit 2 instead of tracebacking.
639
+ print(f"codereview loop: writing {summary_path} "
640
+ f"failed: {exc}", file=sys.stderr)
641
+ print(f"codereview loop: final gate = "
642
+ f"{result['final_gate'].upper()} — "
643
+ f"{result['stop_reason']}")
644
+ for fallback in (os.getcwd(), tempfile.gettempdir()):
645
+ try:
646
+ alt = os.path.join(fallback, "loop-result.json")
647
+ with open(alt, "w", encoding="utf-8") as fh:
648
+ json.dump(summary, fh, ensure_ascii=False,
649
+ indent=1)
650
+ print(f"codereview loop: summary saved to "
651
+ f"fallback: {alt}")
652
+ break
653
+ except OSError:
654
+ continue
655
+ return 2
656
+ print(f"codereview loop: final gate = "
657
+ f"{result['final_gate'].upper()} — {result['stop_reason']}")
658
+ print(f" rounds: {len(result['rounds'])} "
659
+ f"(details in {args.out}/round-*/ and loop-result.json)")
660
+ return {"pass": 0, "blocked": 1, "inconclusive": 2}.get(
661
+ result["final_gate"], 2)
662
+
663
+
664
+ def _workflow_main(argv, providers_override=None):
665
+ """codereview workflow — run a multi-step workflow file; each
666
+ step is gated by the review loop and committed on approval.
667
+ State lives in <out>/workflow-state.json (edit its `waivers`
668
+ map to record owner waivers, then rerun to resume); touching
669
+ <out>/STOP pauses the workflow between steps."""
670
+ ap = argparse.ArgumentParser(prog="codereview workflow")
671
+ ap.add_argument("--file", required=True,
672
+ help="workflow JSON: {name, steps: [{id, title, "
673
+ "agent_cmd, test_commands?}]}")
674
+ ap.add_argument("--repo", default=".")
675
+ ap.add_argument("--out", default="codereview-workflow-out")
676
+ ap.add_argument("--max-rounds", type=int, default=3)
677
+ ap.add_argument("--strict", action="store_true")
678
+ ap.add_argument("--provider", action="append", default=[])
679
+ args = ap.parse_args(argv)
680
+
681
+ if _refuse_unwritable_out(args.out, "codereview workflow"):
682
+ return 2
683
+ with open(args.file, encoding="utf-8") as fh:
684
+ wf = json.load(fh)
685
+ steps = wf.get("steps") or []
686
+ if not steps:
687
+ print("codereview: workflow file has no steps",
688
+ file=sys.stderr)
689
+ return 2
690
+ if providers_override is not None:
691
+ providers = providers_override
692
+ else:
693
+ specs = args.provider
694
+ if not specs:
695
+ env = (os.environ.get("CODEREVIEW_PROVIDERS") or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
696
+ try:
697
+ specs = [s.strip() for s in env.split(",")
698
+ if s.strip()] \
699
+ or userconfig.default_provider_specs()
700
+ except ValueError as exc:
701
+ print(f"codereview: {exc}", file=sys.stderr)
702
+ return 2
703
+ if not specs:
704
+ print(userconfig.NOT_SET_UP, file=sys.stderr)
705
+ return 2
706
+ try:
707
+ providers = build_providers(specs)
708
+ except Exception as exc: # noqa: BLE001
709
+ print(f"codereview: provider setup failed: {exc}",
710
+ file=sys.stderr)
711
+ return 2
712
+
713
+ os.makedirs(args.out, exist_ok=True)
714
+ state_path = os.path.join(args.out, "workflow-state.json")
715
+ state = {}
716
+ if os.path.exists(state_path):
717
+ with open(state_path, encoding="utf-8") as fh:
718
+ state = json.load(fh)
719
+
720
+ def save_state(s):
721
+ with open(state_path, "w", encoding="utf-8") as fh:
722
+ json.dump(s, fh, ensure_ascii=False, indent=1)
723
+
724
+ result = core.run_workflow(
725
+ steps, providers, args.repo, args.out,
726
+ max_rounds=args.max_rounds, strict=args.strict, state=state,
727
+ save_state=save_state,
728
+ should_stop=lambda: os.path.exists(
729
+ os.path.join(args.out, "STOP")))
730
+ print(f"codereview workflow: {result['workflow_state']}")
731
+ for s in result["steps"]:
732
+ print(f" step {s['id']}: {s['state']}"
733
+ + (f" (commit {s['commit'][:10]})" if s.get("commit")
734
+ else "")
735
+ + (f" — {s['error']}" if s.get("error") else ""))
736
+ return {"complete": 0, "stopped": 1}.get(
737
+ result["workflow_state"], 2)
738
+
739
+
740
+ def _repo_tree(repo, limit=200):
741
+ """A shallow file listing for solver context: `git ls-files`
742
+ when the repo is a git checkout, else a depth-limited walk.
743
+ Returns "" when nothing useful can be listed."""
744
+ try:
745
+ if os.path.isdir(os.path.join(repo, ".git")):
746
+ out = subprocess.run(
747
+ ["git", "-C", repo, "ls-files"],
748
+ capture_output=True, timeout=30, check=False)
749
+ lines = out.stdout.decode("utf-8", errors="replace") \
750
+ .splitlines()
751
+ return "\n".join(lines[:limit])
752
+ found = []
753
+ for dirpath, dirnames, filenames in os.walk(repo):
754
+ depth = os.path.relpath(dirpath, repo).count(os.sep)
755
+ if depth > 2:
756
+ dirnames[:] = []
757
+ continue
758
+ dirnames[:] = [d for d in dirnames
759
+ if not d.startswith(".")]
760
+ for name in sorted(filenames):
761
+ found.append(os.path.relpath(
762
+ os.path.join(dirpath, name), repo))
763
+ if len(found) >= limit:
764
+ break
765
+ return "\n".join(sorted(found)[:limit])
766
+ except Exception: # noqa: BLE001 - the tree is optional context
767
+ return ""
768
+
769
+
770
+ def _intake_main(argv, solvers_override=None):
771
+ """codereview intake — issue report in, solution plan out.
772
+
773
+ The solvers are a separate seat type from the reviewers: they
774
+ answer in Markdown (see providers.build_solvers). The primary
775
+ solver's plan lands in <out>/solution.md for the builder."""
776
+ from . import intake
777
+ from .providers import build_solvers
778
+
779
+ ap = argparse.ArgumentParser(
780
+ prog="codereview intake",
781
+ description="Turn an issue report into an executable "
782
+ "solution plan: solver models read the report "
783
+ "(plus optional context files) and write the "
784
+ "plan a builder then implements.")
785
+ ap.add_argument("report", help="issue report (Markdown)")
786
+ ap.add_argument("--context", action="append", default=[],
787
+ help="context file to include (source files, "
788
+ "logs); repeatable")
789
+ ap.add_argument("--repo", default="",
790
+ help="repository the report is about: its file "
791
+ "listing is included as context")
792
+ ap.add_argument("--provider", action="append", default=[],
793
+ help="solver preset or custom spec; repeatable. "
794
+ "The first seat that answers is the primary. "
795
+ "Default: $CODEREVIEW_PROVIDERS or "
796
+ "gemini,nvidia")
797
+ ap.add_argument("--role", action="append", default=[],
798
+ help="assign the solver role's model: "
799
+ "solver=<provider-spec>; the seat becomes "
800
+ "the primary solver, ahead of --provider "
801
+ "seats. Intake assigns the solver role "
802
+ "only")
803
+ ap.add_argument("--timeout", type=int,
804
+ default=intake.DEFAULT_SOLVER_TIMEOUT,
805
+ help="per-solver deadline in seconds "
806
+ "(default 300)")
807
+ ap.add_argument("--out", default="codereview-intake-out")
808
+ args = ap.parse_args(argv)
809
+
810
+ if args.timeout <= 0:
811
+ print("codereview intake: --timeout must be a positive "
812
+ "number of seconds", file=sys.stderr)
813
+ return 2
814
+ if _refuse_unwritable_out(args.out, "codereview intake"):
815
+ return 2
816
+ try:
817
+ with open(os.path.expanduser(args.report), encoding="utf-8",
818
+ errors="replace") as fh:
819
+ report = fh.read()
820
+ except Exception as exc: # noqa: BLE001
821
+ print(f"codereview intake: could not read the report: {exc}",
822
+ file=sys.stderr)
823
+ return 2
824
+ if not report.strip():
825
+ print("codereview intake: the report is empty.",
826
+ file=sys.stderr)
827
+ return 2
828
+ contexts = []
829
+ for path in args.context:
830
+ try:
831
+ with open(os.path.expanduser(path), encoding="utf-8",
832
+ errors="replace") as fh:
833
+ contexts.append((path, fh.read()))
834
+ except Exception as exc: # noqa: BLE001
835
+ print(f"codereview intake: could not read context "
836
+ f"{path}: {exc}", file=sys.stderr)
837
+ return 2
838
+
839
+ if solvers_override is not None:
840
+ solvers = solvers_override
841
+ else:
842
+ specs = args.provider
843
+ if not specs:
844
+ env = (os.environ.get("CODEREVIEW_PROVIDERS")
845
+ or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
846
+ try:
847
+ specs = [s.strip() for s in env.split(",")
848
+ if s.strip()] \
849
+ or userconfig.default_provider_specs()
850
+ except ValueError as exc:
851
+ print(f"codereview: {exc}", file=sys.stderr)
852
+ return 2
853
+ if args.role:
854
+ try:
855
+ assignments = registry.parse_role_assignments(
856
+ args.role)
857
+ except ValueError as exc:
858
+ print(f"codereview: {exc}", file=sys.stderr)
859
+ return 2
860
+ non_solver = sorted(r for r in assignments
861
+ if r != "solver")
862
+ if non_solver:
863
+ print(f"codereview: intake assigns the solver "
864
+ f"role only; got role(s): "
865
+ f"{', '.join(non_solver)}", file=sys.stderr)
866
+ return 2
867
+ if "solver" in assignments:
868
+ specs = [assignments["solver"]] + specs
869
+ if not specs:
870
+ print(userconfig.NOT_SET_UP, file=sys.stderr)
871
+ return 2
872
+ try:
873
+ solvers = build_solvers(specs, agent_timeout=args.timeout)
874
+ except Exception as exc: # noqa: BLE001
875
+ print(f"codereview: solver setup failed: {exc}",
876
+ file=sys.stderr)
877
+ return 2
878
+
879
+ result = intake.run_intake(
880
+ report, solvers, contexts=contexts,
881
+ repo_tree=_repo_tree(args.repo) if args.repo else "",
882
+ timeout=args.timeout)
883
+ solution_path, manifest_path = intake.write_intake(
884
+ result, args.out, report_name=os.path.basename(args.report))
885
+ print(f"codereview intake: {os.path.basename(args.report)}")
886
+ for s in result["solutions"]:
887
+ if s["ok"]:
888
+ print(f" solver {s['name']}: OK "
889
+ f"({s['seconds']}s, {s['chars']} chars)")
890
+ else:
891
+ print(f" solver {s['name']}: FAILED — {s['error']}")
892
+ if result["ok"]:
893
+ print(f" primary: {result['primary']}")
894
+ print(f" solution: {solution_path}")
895
+ print(f" data: {manifest_path}")
896
+ return 0
897
+ print(" no solver produced a plan — see intake.json",
898
+ file=sys.stderr)
899
+ print(f" data: {manifest_path}")
900
+ return 2
901
+
902
+
903
+ def _pr_main(argv, providers_override=None):
904
+ """codereview pr — review a GitHub pull request where it lives.
905
+
906
+ Read path works without a token on public repos. --post (review
907
+ comment + commit status) needs GITHUB_TOKEN with repo access."""
908
+ from .github import GitHubClient, GitHubError, parse_pr_url, \
909
+ review_pr
910
+
911
+ ap = argparse.ArgumentParser(prog="codereview pr")
912
+ ap.add_argument("--repo", help="OWNER/REPO")
913
+ ap.add_argument("--number", type=int, help="PR number")
914
+ ap.add_argument("--pr", help="full PR URL")
915
+ ap.add_argument("--post", action="store_true",
916
+ help="post the review comment + commit status "
917
+ "(needs GITHUB_TOKEN)")
918
+ ap.add_argument("--request-changes", action="store_true",
919
+ help="use REQUEST_CHANGES when the gate blocks")
920
+ ap.add_argument("--provider", action="append", default=[])
921
+ ap.add_argument("--verify-previous",
922
+ help="previous report JSON: verification round")
923
+ ap.add_argument("--builder-response", default="",
924
+ help="builder's work report (or @file)")
925
+ ap.add_argument("--test-command", action="append", default=[])
926
+ ap.add_argument("--strict", action="store_true")
927
+ ap.add_argument("--allow-major", action="store_true")
928
+ ap.add_argument("--out", default="codereview-pr-out")
929
+ args = ap.parse_args(argv)
930
+ if _refuse_unwritable_out(args.out, "codereview pr"):
931
+ return 2
932
+ try:
933
+ if args.pr:
934
+ owner, repo, number = parse_pr_url(args.pr)
935
+ elif args.repo and args.number:
936
+ owner, repo = args.repo.split("/", 1)
937
+ number = args.number
938
+ else:
939
+ print("codereview pr: give --pr URL or --repo OWNER/REPO "
940
+ "with --number", file=sys.stderr)
941
+ return 2
942
+ except Exception as exc: # noqa: BLE001
943
+ print(f"codereview pr: {exc}", file=sys.stderr)
944
+ return 2
945
+
946
+ if args.post and not os.environ.get("GITHUB_TOKEN", "").strip():
947
+ # P5-02: fail fast — before providers are built, tests
948
+ # run, or any review call is spent. (review_pr re-checks
949
+ # at the library boundary.)
950
+ print("codereview pr: --post needs GITHUB_TOKEN with "
951
+ "repo access", file=sys.stderr)
952
+ return 2
953
+
954
+ builder_response = args.builder_response
955
+ if builder_response.startswith("@"):
956
+ with open(os.path.expanduser(builder_response[1:]),
957
+ encoding="utf-8", errors="replace") as fh:
958
+ builder_response = fh.read()
959
+ previous = None
960
+ if args.verify_previous:
961
+ with open(args.verify_previous, encoding="utf-8") as fh:
962
+ previous = json.load(fh)
963
+
964
+ if providers_override is not None:
965
+ providers = providers_override
966
+ else:
967
+ specs = args.provider
968
+ if not specs:
969
+ env = (os.environ.get("CODEREVIEW_PROVIDERS") or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
970
+ try:
971
+ specs = [s.strip() for s in env.split(",")
972
+ if s.strip()] \
973
+ or userconfig.default_provider_specs()
974
+ except ValueError as exc:
975
+ print(f"codereview: {exc}", file=sys.stderr)
976
+ return 2
977
+ if not specs:
978
+ print(userconfig.NOT_SET_UP, file=sys.stderr)
979
+ return 2
980
+ try:
981
+ providers = build_providers(specs)
982
+ except Exception as exc: # noqa: BLE001
983
+ print(f"codereview: provider setup failed: {exc}",
984
+ file=sys.stderr)
985
+ return 2
986
+
987
+ test_results = _run_tests(args.test_command, ".")
988
+ client = GitHubClient()
989
+ if args.post:
990
+ # L17: a run that crashes after reading the PR must not
991
+ # leave a stale or missing verdict looking like success —
992
+ # mark the head commit pending (best effort, never success).
993
+ def _mark_crashed_pending():
994
+ sha = getattr(client, "head_sha", None)
995
+ if sha and not getattr(client, "status_set", False):
996
+ client.set_status(
997
+ owner, repo, sha, "pending",
998
+ "codereview crashed - no verdict was reached")
999
+ crash_mod.register_crash_hook(_mark_crashed_pending)
1000
+ try:
1001
+ report, actions = review_pr(
1002
+ client, owner, repo, number, providers,
1003
+ post=args.post, request_changes=args.request_changes,
1004
+ previous=previous, builder_response=builder_response,
1005
+ test_results=test_results, strict=args.strict,
1006
+ allow_major=args.allow_major)
1007
+ except GitHubError as exc:
1008
+ print(f"codereview pr: {exc}", file=sys.stderr)
1009
+ return 2
1010
+ try:
1011
+ jpath, mpath = _write_report(report, args.out,
1012
+ "codereview pr")
1013
+ except _ReportWriteFailed:
1014
+ return 2
1015
+ print(f"codereview pr: {owner}/{repo}#{number} — "
1016
+ f"{report['pr']['title']}")
1017
+ print(f" gate = {report['gate'].upper()} "
1018
+ f"({'; '.join(report.get('gate_reasons') or [])})")
1019
+ for a in actions:
1020
+ print(f" posted: {a}")
1021
+ print(f" report: {mpath}")
1022
+ print(f" data: {jpath}")
1023
+ posting = report.get("posting") or {}
1024
+ if posting.get("error"):
1025
+ # P5-03: the report above is written and names what was
1026
+ # already posted; the exit code still reflects the
1027
+ # posting failure.
1028
+ print(f"codereview pr: posting failed: {posting['error']}",
1029
+ file=sys.stderr)
1030
+ return 2
1031
+ return {"pass": 0, "blocked": 1, "inconclusive": 2}.get(
1032
+ report["gate"], 2)
1033
+
1034
+
1035
+ _FAMILY_BY_PRESET = {"gemini": "google", "openai": "openai",
1036
+ "xai": "xai", "nvidia": "nvidia",
1037
+ "deepseek": "deepseek",
1038
+ "anthropic": "anthropic",
1039
+ "minimax": "minimax", "moonshot": "moonshot",
1040
+ "mistral": "mistral",
1041
+ "openrouter": "openrouter"}
1042
+
1043
+
1044
+ def _apply_role_assignments(providers, role_values,
1045
+ agent_timeout=None, herdr_host=None,
1046
+ herdr_seats=None):
1047
+ """Resolve --role <role>=<provider-spec> assignments against
1048
+ the panel: each role's seat is built (once) and merged into
1049
+ `providers` if not already present, so the role's model is
1050
+ guaranteed a seat. Returns (lens_seats, seat_roles,
1051
+ role_models): lens pins for the two review lenses, the
1052
+ role -> seat-name map for the record, and the role ->
1053
+ {seat, provider, model, family} detail the record carries so
1054
+ anyone can see which model served which role. Raises
1055
+ ValueError/ProviderError on a bad assignment — callers turn
1056
+ that into exit 2, never a silent default."""
1057
+ assignments = registry.parse_role_assignments(role_values)
1058
+ lens_seats, seat_roles, role_models = {}, {}, {}
1059
+ for role, spec in assignments.items():
1060
+ desc = describe_spec(spec)
1061
+ record_model = desc["model"]
1062
+ record_family = desc["family"]
1063
+ requested_model = None
1064
+ built = build_providers([spec],
1065
+ agent_timeout=agent_timeout,
1066
+ herdr_host=herdr_host,
1067
+ herdr_seats=herdr_seats)
1068
+ (seat_name, seat_fn), = built.items()
1069
+ if seat_name in providers:
1070
+ # Cross-kind collision (internal review, refutation-
1071
+ # weak finding on this seam): an agent role seat whose
1072
+ # name an API seat already owns (or the reverse) must
1073
+ # not silently record the role as served by the other
1074
+ # kind of seat. When the existing seat carries kind/
1075
+ # family tags that prove the mismatch, refuse loudly —
1076
+ # the same rule build_providers enforces inside one
1077
+ # spec list. Untagged override seats can't prove a
1078
+ # mismatch and keep the historical merge.
1079
+ existing = providers[seat_name]
1080
+ existing_tags = (
1081
+ getattr(existing, "_codereview_kind", None),
1082
+ getattr(existing, "_codereview_family", None))
1083
+ existing_is_agent = "agent" in existing_tags
1084
+ if (desc["kind"] == "agent") != existing_is_agent \
1085
+ and any(t is not None for t in existing_tags):
1086
+ raise ProviderError(
1087
+ f"role {role!r}: seat name {seat_name!r} is "
1088
+ f"already used by a different kind of seat "
1089
+ f"(agent vs API); rename the seat in the "
1090
+ f"role spec {spec!r}")
1091
+ # Record fidelity (ratified fix, 78ddf4d5ea27): the
1092
+ # seat built from the role spec is DISCARDED here —
1093
+ # the role is served by the EXISTING seat, whose model
1094
+ # may differ from the role spec's (a pooled preset
1095
+ # seat serves its own default, not a same-name
1096
+ # override). Crediting the spec's model would record
1097
+ # a model that never serves. When the existing seat's
1098
+ # served model(s) are known (tagged at build time),
1099
+ # the record carries the SERVED model and preserves
1100
+ # the requested one alongside; a seat serving several
1101
+ # distinct models cannot honestly be credited with
1102
+ # any single one, so the assignment is refused.
1103
+ served_models = getattr(existing,
1104
+ "_codereview_models", None)
1105
+ if desc["model"] is not None and served_models:
1106
+ if len(served_models) > 1:
1107
+ raise ProviderError(
1108
+ f"role {role!r}: seat {seat_name!r} "
1109
+ f"already exists and its accounts serve "
1110
+ f"different models "
1111
+ f"{list(served_models)}; the role cannot "
1112
+ f"be credited to one model — give the "
1113
+ f"role its own seat via a distinct spec")
1114
+ if desc["model"] != served_models[0]:
1115
+ requested_model = desc["model"]
1116
+ record_model = served_models[0]
1117
+ existing_family = getattr(
1118
+ existing, "_codereview_family", None)
1119
+ if existing_family:
1120
+ record_family = existing_family
1121
+ else:
1122
+ providers[seat_name] = seat_fn
1123
+ seat_roles[role] = seat_name
1124
+ role_models[role] = {
1125
+ "seat": seat_name,
1126
+ "provider": desc["preset"] or desc["kind"],
1127
+ "model": record_model,
1128
+ "family": record_family,
1129
+ "key_env": desc["key_env"],
1130
+ }
1131
+ if requested_model is not None:
1132
+ role_models[role]["requested_model"] = requested_model
1133
+ if role in registry.LENS_ROLES:
1134
+ lens_seats[registry.LENS_ROLES[role]] = seat_name
1135
+ tech_lead = seat_roles.get("tech-lead")
1136
+ return lens_seats, seat_roles, role_models, tech_lead
1137
+
1138
+
1139
+ def _default_seat_meta(providers):
1140
+ """Baseline family tags from seat names (v0.7.3): a preset seat
1141
+ carries its vendor family; an agent seat carries the vendor
1142
+ family of the CLI it runs when that CLI is known (P6-06:
1143
+ claude -> anthropic, codex -> openai, gemini -> google,
1144
+ derived by build_providers from the seat's spec), else the
1145
+ generic family 'agent'. Tier stays 'unknown' unless tagged
1146
+ — recorded, never guessed.
1147
+
1148
+ Agent seats are detected by the kind tag build_providers puts
1149
+ on the callable, NOT by name shape: resolved agent seat names
1150
+ are bare (bot, bot-2, ...) and can collide with preset names,
1151
+ so name-prefix guessing both missed suffixed agent seats and
1152
+ could tag an agent named like a preset with the preset's
1153
+ vendor family (review-v11 finding)."""
1154
+ meta = {}
1155
+ for name, fn in providers.items():
1156
+ if getattr(fn, "_codereview_kind", None) == "agent" \
1157
+ or name.startswith("agent:"):
1158
+ tagged = getattr(fn, "_codereview_family", None)
1159
+ meta[name] = {"family": tagged or "agent",
1160
+ "tier": "unknown"}
1161
+ continue
1162
+ tagged = getattr(fn, "_codereview_family", None)
1163
+ if tagged and tagged != "unknown":
1164
+ # The registry family the seat was BUILT from wins
1165
+ # over name guessing: a seat named 'kimi' from the
1166
+ # moonshot preset is family 'moonshot'.
1167
+ meta[name] = {"family": tagged, "tier": "unknown"}
1168
+ continue
1169
+ base = name.split("-")[0].lower() if name else ""
1170
+ family = _FAMILY_BY_PRESET.get(base)
1171
+ meta[name] = {"family": family or "unknown",
1172
+ "tier": "unknown"}
1173
+ return meta
1174
+
1175
+
1176
+ def _parse_seat_meta(values, base=None):
1177
+ """--seat-meta name:family[:tier] entries over a baseline."""
1178
+ meta = dict(base or {})
1179
+ for value in values or []:
1180
+ parts = value.split(":")
1181
+ if len(parts) < 2 or not parts[0] or not parts[1]:
1182
+ raise ValueError(
1183
+ f"bad --seat-meta {value!r}: want name:family[:tier]")
1184
+ entry = dict(meta.get(parts[0]) or {})
1185
+ entry["family"] = parts[1]
1186
+ if len(parts) > 2 and parts[2]:
1187
+ entry["tier"] = parts[2]
1188
+ entry.setdefault("tier", "unknown")
1189
+ meta[parts[0]] = entry
1190
+ return meta
1191
+
1192
+
1193
+ def _doctor_main(argv, providers_override=None,
1194
+ fallbacks_override=None):
1195
+ ap = argparse.ArgumentParser(
1196
+ prog="codereview doctor",
1197
+ description="Preflight: probe every configured seat — "
1198
+ "primaries AND fallbacks — with the exact "
1199
+ "review request shape and report health as "
1200
+ "JSON before any review runs.")
1201
+ ap.add_argument("--provider", action="append", default=[],
1202
+ help="provider spec (same syntax as review); "
1203
+ "repeatable. Default: $CODEREVIEW_PROVIDERS "
1204
+ "or your saved setup (codereview setup)")
1205
+ ap.add_argument("--fallback", action="append", default=[],
1206
+ help="fallback seat to probe too: "
1207
+ "lens=provider-spec (same syntax as "
1208
+ "review); repeatable")
1209
+ ap.add_argument("--role", action="append", default=[],
1210
+ help="role seat to probe too: "
1211
+ "<role>=<provider-spec> (same roles as "
1212
+ "review); repeatable")
1213
+ ap.add_argument("--timeout", type=int, default=None,
1214
+ help="per-seat timeout in seconds for agent "
1215
+ "seats (forwarded to provider "
1216
+ "construction, like review/intake); "
1217
+ "also the probe's per-seat wait bound "
1218
+ "(default: the review path's per-call "
1219
+ "ceiling)")
1220
+ args = ap.parse_args(argv)
1221
+ if args.timeout is not None and args.timeout <= 0:
1222
+ print(json.dumps(
1223
+ {"ok": False, "seats": [],
1224
+ "error": "--timeout must be a positive number "
1225
+ "of seconds"}))
1226
+ return 2
1227
+ if providers_override is not None:
1228
+ providers = providers_override
1229
+ else:
1230
+ specs = args.provider
1231
+ if not specs:
1232
+ env = (os.environ.get("CODEREVIEW_PROVIDERS")
1233
+ or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
1234
+ try:
1235
+ specs = [s.strip() for s in env.split(",")
1236
+ if s.strip()] \
1237
+ or userconfig.default_provider_specs()
1238
+ except ValueError as exc:
1239
+ print(f"codereview: {exc}", file=sys.stderr)
1240
+ return 2
1241
+ if not specs:
1242
+ print(json.dumps(
1243
+ {"ok": False, "seats": [],
1244
+ "error": "not set up: pass --provider, set "
1245
+ "$CODEREVIEW_PROVIDERS, or run "
1246
+ "`codereview setup`"}))
1247
+ return 2
1248
+ try:
1249
+ providers = build_providers(
1250
+ specs, agent_timeout=args.timeout)
1251
+ except Exception as exc: # noqa: BLE001
1252
+ print(json.dumps({"ok": False, "seats": [],
1253
+ "error": f"provider setup failed: "
1254
+ f"{exc}"}))
1255
+ return 2
1256
+ fallbacks = fallbacks_override
1257
+ if fallbacks is None and args.fallback:
1258
+ fallbacks = {}
1259
+ for spec in args.fallback:
1260
+ lens, eq, pspec = spec.partition("=")
1261
+ lens = lens.strip()
1262
+ if not eq or lens not in core.LENSES \
1263
+ or not pspec.strip():
1264
+ print(json.dumps(
1265
+ {"ok": False, "seats": [],
1266
+ "error": f"bad --fallback {spec!r}: want "
1267
+ f"lens=provider-spec with lens one "
1268
+ f"of {sorted(core.LENSES)}"}))
1269
+ return 2
1270
+ try:
1271
+ built = build_providers(
1272
+ [pspec.strip()], agent_timeout=args.timeout)
1273
+ except Exception as exc: # noqa: BLE001
1274
+ print(json.dumps(
1275
+ {"ok": False, "seats": [],
1276
+ "error": f"fallback setup failed: {exc}"}))
1277
+ return 2
1278
+ (fb_name, fb_fn), = built.items()
1279
+ if fb_name in providers:
1280
+ # Same guard as the review path (764e1d15a218):
1281
+ # a fallback that duplicates a primary seat is
1282
+ # a configuration error, not a second opinion.
1283
+ print(json.dumps(
1284
+ {"ok": False, "seats": [],
1285
+ "error": f"fallback seat {fb_name!r} is "
1286
+ f"already a primary seat"}))
1287
+ return 2
1288
+ fallbacks[lens] = (fb_name, fb_fn)
1289
+ seat_roles, role_models = {}, {}
1290
+ if args.role:
1291
+ try:
1292
+ _lens, seat_roles, role_models, _tl = \
1293
+ _apply_role_assignments(
1294
+ providers, args.role,
1295
+ agent_timeout=args.timeout)
1296
+ except Exception as exc: # noqa: BLE001
1297
+ print(json.dumps(
1298
+ {"ok": False, "seats": [],
1299
+ "error": f"role assignment failed: {exc}"}))
1300
+ return 2
1301
+ result = core.run_doctor(providers, fallbacks=fallbacks,
1302
+ timeout=args.timeout)
1303
+ if seat_roles:
1304
+ result = {**result, "seat_roles": seat_roles,
1305
+ "role_models": role_models}
1306
+ print(json.dumps(result, ensure_ascii=False, indent=1))
1307
+ return 0 if result["ok"] else 2
1308
+
1309
+
1310
+ def _timestamped_record_name(name):
1311
+ """True for write_report's record stem
1312
+ (codereview-YYYYMMDD-HHMMSS...): the filename itself carries
1313
+ the run timestamp, so name order is chronological."""
1314
+ if not name.startswith("codereview-"):
1315
+ return False
1316
+ stamp = name[len("codereview-"):]
1317
+ return (len(stamp) >= 15 and stamp[8] == "-"
1318
+ and stamp[:8].isdigit() and stamp[9:15].isdigit())
1319
+
1320
+
1321
+ def _load_record_arg(path, repo):
1322
+ """Resolve a record argument (directory or JSON file) to
1323
+ (record, record_dir, jpath). record_dir is repo-relative —
1324
+ the head-currency checks allow later commits that touch only
1325
+ the record directories."""
1326
+ if os.path.isdir(path):
1327
+ candidates = [os.path.join(path, f)
1328
+ for f in os.listdir(path)
1329
+ if f.endswith(".json")]
1330
+ if not candidates:
1331
+ raise ValueError(f"no record JSON in {path}")
1332
+ stamped = [p for p in candidates
1333
+ if _timestamped_record_name(
1334
+ os.path.basename(p))]
1335
+ if stamped:
1336
+ # Prefer the lexicographically greatest timestamped
1337
+ # record name: name order is chronological AND
1338
+ # copy-stable, while mtime order breaks on copied or
1339
+ # re-exported record trees — and an auxiliary JSON
1340
+ # sharing the directory could otherwise outrank the
1341
+ # actual record (179ece0e030e). mtime remains the
1342
+ # fallback when no candidate carries a timestamp.
1343
+ jpath = max(stamped)
1344
+ else:
1345
+ jpath = max(candidates, key=os.path.getmtime)
1346
+ record_dir = os.path.relpath(
1347
+ os.path.abspath(path),
1348
+ os.path.abspath(repo)).replace(os.sep, "/")
1349
+ else:
1350
+ jpath = path
1351
+ record_dir = os.path.relpath(
1352
+ os.path.abspath(os.path.dirname(path) or "."),
1353
+ os.path.abspath(repo)).replace(os.sep, "/")
1354
+ with open(jpath, encoding="utf-8") as fh:
1355
+ record = json.load(fh)
1356
+ return record, record_dir, jpath
1357
+
1358
+
1359
+ def _print_verify_result(result, label):
1360
+ for check in result["checks"]:
1361
+ mark = "ok " if check["ok"] else "FAIL"
1362
+ print(f" [{mark}] {check['name']}: {check['detail']}")
1363
+ for warning in result.get("warnings") or []:
1364
+ print(f" [WARN] {warning['name']}: {warning['detail']}")
1365
+ for entry in result.get("dispositions_consumed") or []:
1366
+ print(f" [store] disposition consumed: "
1367
+ f"{entry.get('finding_id')}: {entry.get('type')} "
1368
+ f"by {entry.get('decider')} ({entry.get('date')})")
1369
+ print(f"codereview: {label} "
1370
+ f"{'ELIGIBLE' if result['eligible'] else 'NOT eligible'}")
1371
+
1372
+
1373
+ def _record_verify_main(argv):
1374
+ ap = argparse.ArgumentParser(
1375
+ prog="codereview record-verify",
1376
+ description="Check whether a saved review record is "
1377
+ "eligible as a release record (clean gate, "
1378
+ "full panel, current range).")
1379
+ ap.add_argument("path", nargs="?",
1380
+ help="record directory or record JSON file")
1381
+ ap.add_argument("--repo", default=".",
1382
+ help="repository the record belongs to")
1383
+ ap.add_argument("--chain", nargs="+", metavar="RECORD",
1384
+ help="Form B: verify an ordered chain of "
1385
+ "records (round-1 review followed by "
1386
+ "verification rounds) as one "
1387
+ "round-structured record. Convergence-"
1388
+ "budget breaches are reported as WARN "
1389
+ "lines — process flags for the owner — "
1390
+ "and do not by themselves change the "
1391
+ "exit code; eligibility is decided by "
1392
+ "the checks alone.")
1393
+ ap.add_argument("--dispositions",
1394
+ help="disposition store (JSONL) to consult "
1395
+ "(§5.2): disputed findings with a store "
1396
+ "entry count as dispositioned, and the "
1397
+ "consumed entries are reported. Default: "
1398
+ "the store at <repo>/dispositions.jsonl "
1399
+ "when one exists; without any store, "
1400
+ "verification behaves exactly as before.")
1401
+ args = ap.parse_args(argv)
1402
+ dispositions = None
1403
+ store_path = args.dispositions or disputes_mod.store_path_for(
1404
+ args.repo)
1405
+ if args.dispositions or os.path.isfile(store_path):
1406
+ loaded = disputes_mod.load_store(store_path)
1407
+ if loaded["problems"]:
1408
+ print("codereview: the disposition store has "
1409
+ "problems; refusing to verify against it:",
1410
+ file=sys.stderr)
1411
+ for problem in loaded["problems"]:
1412
+ print(f" {problem}", file=sys.stderr)
1413
+ return 2
1414
+ dispositions = loaded["entries"]
1415
+ print(f"codereview: disposition store: "
1416
+ f"{len(dispositions)} entr(ies) from {store_path}",
1417
+ file=sys.stderr)
1418
+ try:
1419
+ if args.chain:
1420
+ records, dirs = [], []
1421
+ for p in args.chain:
1422
+ record, record_dir, _jp = _load_record_arg(
1423
+ p, args.repo)
1424
+ records.append(record)
1425
+ dirs.append(record_dir)
1426
+ result = core.verify_chain(
1427
+ records, repo=args.repo, record_dirs=dirs,
1428
+ dispositions=dispositions)
1429
+ _print_verify_result(
1430
+ result,
1431
+ f"chain of {len(records)} record(s)")
1432
+ return 0 if result["eligible"] else 1
1433
+ if not args.path:
1434
+ ap.error("a record path is required (or --chain)")
1435
+ record, record_dir, jpath = _load_record_arg(
1436
+ args.path, args.repo)
1437
+ except Exception as exc: # noqa: BLE001
1438
+ print(f"codereview: could not read the record: {exc}",
1439
+ file=sys.stderr)
1440
+ return 2
1441
+ result = core.verify_record(record, repo=args.repo,
1442
+ record_dir=record_dir,
1443
+ dispositions=dispositions)
1444
+ _print_verify_result(result, f"record ({jpath})")
1445
+ return 0 if result["eligible"] else 1
1446
+
1447
+
1448
+ def _disputes_main(argv):
1449
+ """codereview disputes — the computed open-dispute
1450
+ inventory (§5.1), the disposition-store append (§5.2), and
1451
+ the release-flow freshness check (§5.3)."""
1452
+ ap = argparse.ArgumentParser(
1453
+ prog="codereview disputes",
1454
+ description="Compute the open-dispute inventory from "
1455
+ "the review records under a repo: findings "
1456
+ "in a human-decision state (disputed or "
1457
+ "refutation-weak) with no disposition in "
1458
+ "the store. With --record, append one "
1459
+ "disposition entry instead. With --check, "
1460
+ "act as the release-flow freshness gate.")
1461
+ ap.add_argument("--repo", default=".",
1462
+ help="repository whose review-*/ records "
1463
+ "and disposition store are read "
1464
+ "(default: .)")
1465
+ ap.add_argument("--store",
1466
+ help="disposition store path (default: "
1467
+ "<repo>/dispositions.jsonl)")
1468
+ ap.add_argument("--json", action="store_true",
1469
+ help="print the inventory as JSON")
1470
+ ap.add_argument("--record", metavar="FILE",
1471
+ help="append the disposition entry in FILE "
1472
+ "(JSON object; '-' reads stdin) to the "
1473
+ "store, with validation — a fingerprint "
1474
+ "may be dispositioned once; a second "
1475
+ "entry must be an override naming the "
1476
+ "entry it supersedes")
1477
+ ap.add_argument("--check", action="store_true",
1478
+ help="freshness gate: exit 1 listing every "
1479
+ "open dispute in the checked records "
1480
+ "that is neither dispositioned nor "
1481
+ "named in the --inventory file")
1482
+ ap.add_argument("--span", metavar="A..B",
1483
+ help="with --check: only records touching "
1484
+ "this commit span count (default: all "
1485
+ "records under --repo)")
1486
+ ap.add_argument("--inventory", metavar="FILE",
1487
+ help="with --check: a caller-supplied "
1488
+ "inventory (JSON) whose finding ids "
1489
+ "are excused from the gate")
1490
+ ap.add_argument("--overrides", metavar="FILE",
1491
+ help="tagger manual overrides (JSON): "
1492
+ "{\"authority\": [ids], "
1493
+ "\"not_authority\": [ids]}")
1494
+ args = ap.parse_args(argv)
1495
+ store_path = args.store or disputes_mod.store_path_for(
1496
+ args.repo)
1497
+ loaded = disputes_mod.load_store(store_path)
1498
+ if loaded["problems"]:
1499
+ print("codereview: the disposition store has problems:",
1500
+ file=sys.stderr)
1501
+ for problem in loaded["problems"]:
1502
+ print(f" {problem}", file=sys.stderr)
1503
+ return 2
1504
+ entries = loaded["entries"]
1505
+
1506
+ if args.record:
1507
+ try:
1508
+ raw = sys.stdin.read() if args.record == "-" else \
1509
+ open(args.record, encoding="utf-8").read()
1510
+ entry = json.loads(raw)
1511
+ except Exception as exc: # noqa: BLE001
1512
+ print(f"codereview: could not read the entry: {exc}",
1513
+ file=sys.stderr)
1514
+ return 2
1515
+ try:
1516
+ stored = disputes_mod.append_entry(store_path, entry)
1517
+ except ValueError as exc:
1518
+ print(f"codereview: entry rejected: {exc}",
1519
+ file=sys.stderr)
1520
+ return 2
1521
+ print(f"codereview: disposition recorded: "
1522
+ f"{stored['finding_id']}: {stored['type']} by "
1523
+ f"{stored['decider']} ({stored['date']}), "
1524
+ f"entry sha256 "
1525
+ f"{disputes_mod.entry_hash(stored)[:12]}…")
1526
+ return 0
1527
+
1528
+ overrides = None
1529
+ if args.overrides:
1530
+ try:
1531
+ with open(args.overrides, encoding="utf-8") as fh:
1532
+ overrides = json.load(fh)
1533
+ except Exception as exc: # noqa: BLE001
1534
+ print(f"codereview: could not read the overrides: "
1535
+ f"{exc}", file=sys.stderr)
1536
+ return 2
1537
+
1538
+ if args.check:
1539
+ inventory_ids = set()
1540
+ if args.inventory:
1541
+ try:
1542
+ inventory_ids = \
1543
+ disputes_mod.inventory_ids_from_file(
1544
+ args.inventory)
1545
+ except Exception as exc: # noqa: BLE001
1546
+ print(f"codereview: could not read the "
1547
+ f"inventory file: {exc}", file=sys.stderr)
1548
+ return 2
1549
+ if args.span:
1550
+ try:
1551
+ result = disputes_mod.check_freshness(
1552
+ args.repo, args.repo, args.span, entries,
1553
+ inventory_ids)
1554
+ except ValueError as exc:
1555
+ print(f"codereview: {exc}", file=sys.stderr)
1556
+ return 2
1557
+ scope = f"span {args.span}"
1558
+ else:
1559
+ inv = disputes_mod.build_inventory(
1560
+ args.repo, entries, overrides)
1561
+ result = {"violations": [
1562
+ row for row in inv["open"]
1563
+ if row["id"] not in inventory_ids],
1564
+ "touching": sorted({row["record"] for row in
1565
+ inv["open"]}),
1566
+ "unscoped": []}
1567
+ scope = "all records"
1568
+ for row in result["violations"]:
1569
+ print(f" UNINVENTORIED {row['id']} "
1570
+ f"({row['severity']}/{row['status']}) "
1571
+ f"{row['record']}: {row['title']}")
1572
+ for rel in result["unscoped"]:
1573
+ print(f" note: record {rel} carries no range/head; "
1574
+ f"it cannot be scoped to the span and was not "
1575
+ f"checked", file=sys.stderr)
1576
+ if result["violations"]:
1577
+ print(f"codereview: freshness check FAILED "
1578
+ f"({scope}): {len(result['violations'])} "
1579
+ f"open dispute(s) neither dispositioned nor "
1580
+ f"inventoried")
1581
+ return 1
1582
+ print(f"codereview: freshness check ok ({scope}): no "
1583
+ f"un-inventoried open disputes")
1584
+ return 0
1585
+
1586
+ inv = disputes_mod.build_inventory(args.repo, entries,
1587
+ overrides)
1588
+ if args.json:
1589
+ print(json.dumps(inv, ensure_ascii=False, indent=2))
1590
+ return 0
1591
+ by_record = {}
1592
+ for row in inv["open"]:
1593
+ by_record.setdefault(row["record"], []).append(row)
1594
+ for rel in sorted(by_record):
1595
+ print(f"{rel}:")
1596
+ for row in by_record[rel]:
1597
+ tag = row["authority"]
1598
+ raiser = (row["raiser_family"] or row["raiser"]
1599
+ or "?")
1600
+ refuter = (row["refuter_family"] or row["refuter"]
1601
+ or "?")
1602
+ loc = (f"{row['file']}:{row['line']}"
1603
+ if row["line"] else row["file"])
1604
+ if tag["authority_affecting"]:
1605
+ auth = "YES — " + ", ".join(
1606
+ tag["dimensions"] or ["(override)"])
1607
+ else:
1608
+ auth = "no"
1609
+ print(f" {row['id']} {row['severity']} "
1610
+ f"{row['status']} {loc} "
1611
+ f"[{raiser} -> refuted by {refuter}]")
1612
+ print(f" {row['title']}")
1613
+ print(f" authority-affecting: {auth}")
1614
+ n_auth = sum(1 for r in inv["open"]
1615
+ if r["authority"]["authority_affecting"])
1616
+ print(f"codereview: {len(inv['open'])} open dispute(s) "
1617
+ f"across {inv['records_scanned']} record(s) "
1618
+ f"({n_auth} authority-affecting); "
1619
+ f"{len(inv['dispositioned'])} dispute(s) already "
1620
+ f"dispositioned in the store ({inv['store_entries']} "
1621
+ f"entr(ies) at {store_path})")
1622
+ return 0
1623
+
1624
+
1625
+ def _locate_main(argv):
1626
+ """codereview locate <finding-id> --record <path> — resolve
1627
+ a finding's validated quote to its blob position(s) at the
1628
+ reviewed commit (§5.6)."""
1629
+ ap = argparse.ArgumentParser(
1630
+ prog="codereview locate",
1631
+ description="Resolve a finding's validated evidence "
1632
+ "quote to its position(s) in the cited file "
1633
+ "as committed at the record's reviewed "
1634
+ "commit. The quote is the anchor; the cited "
1635
+ "line number is only checked against it.")
1636
+ ap.add_argument("finding_id",
1637
+ help="the finding's fingerprint (id)")
1638
+ ap.add_argument("--record", required=True,
1639
+ help="record directory or record JSON file")
1640
+ ap.add_argument("--repo", default=".",
1641
+ help="repository the record belongs to")
1642
+ args = ap.parse_args(argv)
1643
+ try:
1644
+ record, record_dir, jpath = _load_record_arg(
1645
+ args.record, args.repo)
1646
+ except Exception as exc: # noqa: BLE001
1647
+ print(f"codereview: could not read the record: {exc}",
1648
+ file=sys.stderr)
1649
+ return 2
1650
+ result = disputes_mod.locate_finding(
1651
+ record, args.finding_id, args.repo, record_dir)
1652
+ if result is None:
1653
+ print(f"codereview: finding {args.finding_id} is not in "
1654
+ f"{jpath}", file=sys.stderr)
1655
+ return 2
1656
+ print(f"finding {result['finding_id']}: {result['title']}")
1657
+ print(f" file: {result['file']} cited line: "
1658
+ f"{result['cited_line']} line_basis: "
1659
+ f"{result['line_basis'] or '(not recorded)'}")
1660
+ print(f" quote ({result['quote_source']}, from "
1661
+ f"{result['quote_from']}): {result['quote'][:200]}")
1662
+ if result["blob_error"]:
1663
+ print(f" blob at {result['blob_ref']}: unreadable — "
1664
+ f"{result['blob_error']}")
1665
+ return 1
1666
+ if not result["positions"]:
1667
+ print(f" quote not found in {result['file']} at "
1668
+ f"{result['blob_ref'][:12]}… — the citation does "
1669
+ f"not resolve at the reviewed commit")
1670
+ return 1
1671
+ print(f" blob positions at {result['blob_ref'][:12]}…: "
1672
+ f"{result['positions']}")
1673
+ resolves = ("yes" if result["cited_matches"] else
1674
+ "NO — the numbering basis differs "
1675
+ "(see line_basis)")
1676
+ print(f" cited line resolves: {resolves}")
1677
+ return 0
1678
+
1679
+
1680
+ def _sarif_main(argv):
1681
+ """codereview sarif — export a saved record's open findings
1682
+ as a SARIF 2.1.0 log (Epic D, ticket D1), for GitHub code
1683
+ scanning and other SARIF consumers. An export, not a gate:
1684
+ exit 0 means the log was written, whatever the record's
1685
+ gate was; exit 2 means the record could not be read."""
1686
+ ap = argparse.ArgumentParser(
1687
+ prog="codereview sarif",
1688
+ description="Export a saved review record as SARIF "
1689
+ "2.1.0 (open findings only; see "
1690
+ "codereview.sarif for the mapping).")
1691
+ ap.add_argument("path",
1692
+ help="record directory or record JSON file")
1693
+ ap.add_argument("--repo", default=".",
1694
+ help="repository the record belongs to")
1695
+ ap.add_argument("--out",
1696
+ help="write the SARIF log here "
1697
+ "(default: stdout)")
1698
+ args = ap.parse_args(argv)
1699
+ try:
1700
+ record, _record_dir, jpath = _load_record_arg(
1701
+ args.path, args.repo)
1702
+ except Exception as exc: # noqa: BLE001
1703
+ print(f"codereview: could not read the record: {exc}",
1704
+ file=sys.stderr)
1705
+ return 2
1706
+ log = sarif_mod.record_to_sarif(record)
1707
+ text = json.dumps(log, ensure_ascii=False, indent=2) + "\n"
1708
+ run = log["runs"][0]
1709
+ if args.out:
1710
+ with open(args.out, "w", encoding="utf-8") as fh:
1711
+ fh.write(text)
1712
+ print(f"codereview sarif: {jpath} -> {args.out} "
1713
+ f"({len(run['results'])} result(s), "
1714
+ f"{len(run['tool']['driver']['rules'])} rule(s); "
1715
+ f"record gate = "
1716
+ f"{str(record.get('gate') or 'unknown').upper()})")
1717
+ else:
1718
+ sys.stdout.write(text)
1719
+ return 0
1720
+
1721
+
1722
+ def _mcp_main(argv):
1723
+ """codereview mcp serve — serve the gate over the Model
1724
+ Context Protocol (Epic L2): a stdio server exposing
1725
+ run_gate / get_verdict so an MCP-speaking agent can invoke
1726
+ the gate from inside its own loop. Serving only — the
1727
+ server is a front door onto this same CLI (a run goes
1728
+ through main() itself), holds no credentials of its own,
1729
+ and draws seats only from the saved setup / environment,
1730
+ exactly like a CLI run. Protocol on stdout; all
1731
+ diagnostics on stderr."""
1732
+ if argv and argv[0] in ("-h", "--help"):
1733
+ ap = argparse.ArgumentParser(
1734
+ prog="codereview mcp",
1735
+ description="Serve the CodeReview gate over the "
1736
+ "Model Context Protocol.")
1737
+ ap.add_argument("action", choices=("serve",),
1738
+ help="serve — serve the gate on stdio "
1739
+ "(tools: run_gate, get_verdict)")
1740
+ ap.parse_args(argv)
1741
+ if not argv or argv[0] != "serve":
1742
+ print("usage: codereview mcp serve", file=sys.stderr)
1743
+ return 2
1744
+ ap = argparse.ArgumentParser(
1745
+ prog="codereview mcp serve",
1746
+ description="Serve the CodeReview gate over MCP "
1747
+ "(stdio transport; tools: run_gate, "
1748
+ "get_verdict).")
1749
+ ap.parse_args(argv[1:])
1750
+ from . import mcp_tools
1751
+ print(f"codereview mcp: serving the gate on stdio "
1752
+ f"(server {mcp_tools.SERVER_INFO['name']} "
1753
+ f"{mcp_tools.SERVER_INFO['version']}; seats from "
1754
+ f"your saved setup)", file=sys.stderr)
1755
+ mcp_tools.serve_stdio()
1756
+ return 0
1757
+
1758
+
1759
+ def _policy_main(argv):
1760
+ """codereview policy check — validate the project's policy
1761
+ file (.codereview.toml, Epic B) and print the effective
1762
+ policy: the file's rules with every default filled in. With
1763
+ no policy file, the effective policy is the defaults — no
1764
+ requirements — and that is a valid state, printed as such."""
1765
+ if argv and argv[0] in ("-h", "--help"):
1766
+ ap = argparse.ArgumentParser(
1767
+ prog="codereview policy",
1768
+ description="Validate and print the effective "
1769
+ "project policy.")
1770
+ ap.add_argument("action", choices=("check",),
1771
+ help="check — validate the policy file "
1772
+ "and print the effective policy")
1773
+ ap.parse_args(argv)
1774
+ if not argv or argv[0] != "check":
1775
+ print("usage: codereview policy check [--repo DIR] "
1776
+ "[--policy PATH]", file=sys.stderr)
1777
+ return 2
1778
+ ap = argparse.ArgumentParser(
1779
+ prog="codereview policy check",
1780
+ description="Validate the project policy file and print "
1781
+ "the effective policy (defaults filled in).")
1782
+ ap.add_argument("--repo", default=".",
1783
+ help="repository root (default: .); the "
1784
+ "policy file is .codereview.toml there")
1785
+ ap.add_argument("--policy",
1786
+ help="policy file path (default: "
1787
+ "<repo>/.codereview.toml)")
1788
+ args = ap.parse_args(argv[1:])
1789
+ path = args.policy or os.path.join(
1790
+ args.repo, policy_mod.POLICY_FILENAME)
1791
+ if not os.path.exists(path):
1792
+ print(f"codereview policy: no policy file at {path} — "
1793
+ f"the default policy is in effect (no "
1794
+ f"requirements):")
1795
+ print(policy_mod.render_effective(
1796
+ policy_mod.default_policy()))
1797
+ return 0
1798
+ try:
1799
+ loaded = policy_mod.load_policy(path)
1800
+ except Exception as exc: # noqa: BLE001
1801
+ print(f"codereview policy: {exc}", file=sys.stderr)
1802
+ return 2
1803
+ print(f"codereview policy: {path} is valid "
1804
+ f"(sha256 {loaded['sha256'][:12]}…). Effective policy:")
1805
+ print(policy_mod.render_effective(loaded))
1806
+ return 0
1807
+
1808
+
1809
+ def _bench_main(argv, providers_override=None):
1810
+ """codereview bench run|report — Epic A benchmark harness."""
1811
+ if argv and argv[0] in ("-h", "--help"):
1812
+ ap = argparse.ArgumentParser(
1813
+ prog="codereview bench",
1814
+ description="Run the benchmark corpus and "
1815
+ "reports (Epic A harness).")
1816
+ ap.add_argument("action", choices=("run", "report"),
1817
+ help="run — run the corpus under a "
1818
+ "config; report — summarise saved "
1819
+ "results")
1820
+ ap.parse_args(argv)
1821
+ if not argv or argv[0] not in ("run", "report"):
1822
+ print("usage: codereview bench run --config NAME [...] "
1823
+ "[--corpus DIR] [--out DIR] [--split all|public|holdout]\n"
1824
+ " codereview bench report [--results DIR] "
1825
+ "[--corpus DIR] [--public] [--out-dir DIR]\n"
1826
+ " [--max-escaped-rate RATE]",
1827
+ file=sys.stderr)
1828
+ return 2
1829
+ action, rest = argv[0], argv[1:]
1830
+ if action == "run":
1831
+ ap = argparse.ArgumentParser(prog="codereview bench run")
1832
+ ap.add_argument("--config", action="append", required=True,
1833
+ help="config name (bench/configs/<name>.json) "
1834
+ "or path to a config JSON; repeatable")
1835
+ ap.add_argument("--corpus", default="bench/corpus",
1836
+ help="corpus directory (default: bench/corpus)")
1837
+ ap.add_argument("--out", default="bench-out",
1838
+ help="results directory (default: bench-out); "
1839
+ "runs resume — recorded cases are skipped")
1840
+ ap.add_argument("--split", default="all",
1841
+ choices=["all", "public", "holdout"],
1842
+ help="restrict to one corpus split")
1843
+ ap.add_argument("--force", action="store_true",
1844
+ help="discard existing results for the named "
1845
+ "configs and re-run every case")
1846
+ args = ap.parse_args(rest)
1847
+ if _refuse_unwritable_out(args.out, "codereview bench"):
1848
+ return 2
1849
+ injected = None
1850
+ if providers_override is not None:
1851
+ injected = {}
1852
+ for spec in args.config:
1853
+ try:
1854
+ name = bench_mod.load_config(
1855
+ bench_mod.resolve_config_path(
1856
+ spec, args.corpus))["name"]
1857
+ except Exception: # noqa: BLE001
1858
+ continue
1859
+ injected[name] = providers_override
1860
+ try:
1861
+ results = bench_mod.run_bench(
1862
+ args.corpus, args.config, args.out, split=args.split,
1863
+ force=args.force, providers_by_config=injected)
1864
+ except Exception as exc: # noqa: BLE001
1865
+ print(f"codereview bench: {exc}", file=sys.stderr)
1866
+ return 2
1867
+ for name, records in results.items():
1868
+ gates = {}
1869
+ for rec in records:
1870
+ gates[rec["gate"]] = gates.get(rec["gate"], 0) + 1
1871
+ print(f"codereview bench: {name}: {len(records)} case "
1872
+ f"record(s) in {args.out} "
1873
+ f"(gates: {json.dumps(gates, sort_keys=True)})")
1874
+ return 0
1875
+ ap = argparse.ArgumentParser(prog="codereview bench report")
1876
+ ap.add_argument("--results", default="bench-out",
1877
+ help="results directory written by bench run "
1878
+ "(default: bench-out)")
1879
+ ap.add_argument("--corpus", default="bench/corpus",
1880
+ help="corpus directory (default: bench/corpus)")
1881
+ ap.add_argument("--public", action="store_true",
1882
+ help="public report: holdout split excluded "
1883
+ "before scoring (ids and rows never appear)")
1884
+ ap.add_argument("--out-dir",
1885
+ help="also write bench-report.md and "
1886
+ "bench-report.json here")
1887
+ ap.add_argument("--max-escaped-rate", type=float,
1888
+ help="exit 1 if any config's escaped-defect rate "
1889
+ "exceeds this rate (CI regression gate)")
1890
+ args = ap.parse_args(rest)
1891
+ if _refuse_unwritable_out(args.out_dir, "codereview bench"):
1892
+ return 2
1893
+ try:
1894
+ report = bench_mod.build_report(
1895
+ bench_mod.load_results(args.results), args.corpus,
1896
+ public_only=args.public)
1897
+ except Exception as exc: # noqa: BLE001
1898
+ print(f"codereview bench: {exc}", file=sys.stderr)
1899
+ return 2
1900
+ md = bench_mod.render_markdown(report)
1901
+ print(md)
1902
+ if args.out_dir:
1903
+ os.makedirs(args.out_dir, exist_ok=True)
1904
+ stem = "bench-report-public" if args.public else "bench-report"
1905
+ with open(os.path.join(args.out_dir, stem + ".md"), "w",
1906
+ encoding="utf-8") as fh:
1907
+ fh.write(md)
1908
+ with open(os.path.join(args.out_dir, stem + ".json"), "w",
1909
+ encoding="utf-8") as fh:
1910
+ json.dump(report, fh, indent=2)
1911
+ print(f"codereview bench: wrote {stem}.md / {stem}.json "
1912
+ f"to {args.out_dir}")
1913
+ if args.max_escaped_rate is not None:
1914
+ worst = [(name, e["metrics"]["escaped_defect_rate"])
1915
+ for name, e in report["configs"].items()
1916
+ if e["metrics"]["escaped_defect_rate"] is not None
1917
+ and e["metrics"]["escaped_defect_rate"]
1918
+ > args.max_escaped_rate]
1919
+ if worst:
1920
+ for name, rate in worst:
1921
+ print(f"codereview bench: {name} escaped-defect rate "
1922
+ f"{rate:.3f} exceeds --max-escaped-rate "
1923
+ f"{args.max_escaped_rate}", file=sys.stderr)
1924
+ return 1
1925
+ return 0
1926
+
1927
+
1928
+ def _acceptance_main(argv, providers_override=None):
1929
+ """codereview acceptance — run the shadow Tech Lead over
1930
+ the known-answer acceptance corpus and score the adopted
1931
+ gates M1–M6 (refutation-process solution §6). The seat is
1932
+ built from --provider specs (or supplied by the caller via
1933
+ providers_override, as the swarm runner supplies its
1934
+ seats); its family must be a third family relative to the
1935
+ corpus's raiser/refuter families for the run to mean
1936
+ anything."""
1937
+ ap = argparse.ArgumentParser(prog="codereview acceptance")
1938
+ ap.add_argument("--corpus", required=True,
1939
+ help="path to the acceptance corpus "
1940
+ "directory (index.json + cases/ + "
1941
+ "scorecard.json + mutations.json)")
1942
+ ap.add_argument("--provider", action="append", default=[],
1943
+ help="provider spec for the Tech Lead seat, "
1944
+ "e.g. deepseek:deepseek-chat (the "
1945
+ "first seat is used)")
1946
+ ap.add_argument("--seat-family", default=None,
1947
+ help="family tag for the seat (defaults to "
1948
+ "the seat's provider family)")
1949
+ ap.add_argument("--out", default=None,
1950
+ help="write the full run record (JSON) to "
1951
+ "this directory")
1952
+ ap.add_argument("--no-mutations", action="store_true",
1953
+ help="skip the M4 mutation battery")
1954
+ ap.add_argument("--timeout", type=int, default=180,
1955
+ help="per-call timeout in seconds")
1956
+ args = ap.parse_args(argv)
1957
+ if providers_override:
1958
+ seats = dict(providers_override)
1959
+ elif args.provider:
1960
+ try:
1961
+ seats = build_providers(
1962
+ args.provider, agent_timeout=args.timeout)
1963
+ except Exception as exc: # noqa: BLE001
1964
+ print(f"codereview: provider setup failed: {exc}",
1965
+ file=sys.stderr)
1966
+ return 2
1967
+ else:
1968
+ print("codereview: acceptance needs a seat: pass "
1969
+ "--provider <spec>", file=sys.stderr)
1970
+ return 2
1971
+ if not seats:
1972
+ print("codereview: no seat available", file=sys.stderr)
1973
+ return 2
1974
+ seat_name, seat_fn = next(iter(seats.items()))
1975
+ seat_family = args.seat_family or getattr(
1976
+ seat_fn, "_codereview_family", None) or seat_name
1977
+ from . import acceptance as acceptance_mod
1978
+ result = acceptance_mod.run_acceptance(
1979
+ args.corpus, seat_fn, seat_family=seat_family,
1980
+ seat_name=seat_name,
1981
+ do_mutations=not args.no_mutations,
1982
+ timeout=args.timeout,
1983
+ log=lambda m: print(m, file=sys.stderr))
1984
+ for mid, m in result["metrics"].items():
1985
+ print(f"{mid}: measured={m['measured']} "
1986
+ f"threshold={m['threshold']} "
1987
+ f"pass={m['pass']}")
1988
+ print(f"unlock (all gates on one run): "
1989
+ f"{result['unlock_all_gates_pass']}")
1990
+ if args.out:
1991
+ os.makedirs(args.out, exist_ok=True)
1992
+ path = os.path.join(args.out, "acceptance-run.json")
1993
+ with open(path, "w", encoding="utf-8") as fh:
1994
+ json.dump(result, fh, indent=1, default=str)
1995
+ print(f"record: {path}")
1996
+ return 0 if result["unlock_all_gates_pass"] else 1
1997
+
1998
+
1999
+ def _seat_progress(event):
2000
+ """Live seat-state line (Epic K2): one stderr line per seat
2001
+ transition while a review/verification round runs, so a
2002
+ seat stuck on its plan's quota, a login, or a stall is
2003
+ visible as it happens — the same transitions the record
2004
+ persists per seat (seat_state). A seat that ends blocked
2005
+ on quota gets the spec's operator guidance; the panel's
2006
+ degraded handling itself is the gate's, unchanged."""
2007
+ state = event.get("state") or ""
2008
+ cls = event.get("class")
2009
+ label = f"{state}:{cls}" if cls else state
2010
+ print(f"codereview: seat {event.get('seat')}: {label}",
2011
+ file=sys.stderr)
2012
+ if event.get("terminal") and state == "blocked" \
2013
+ and cls == "quota":
2014
+ print(f"codereview: seat {event.get('seat')} hit its "
2015
+ f"plan's allowance; the panel continues degraded "
2016
+ f"— top up or wait for the reset.", file=sys.stderr)
2017
+
2018
+
2019
+ def main(argv=None, providers_override=None, seat_meta_override=None,
2020
+ fallbacks_override=None):
2021
+ # P3-04: the gate process holds provider keys in its
2022
+ # environment and runs untrusted commands (evidence,
2023
+ # probes, agent seats); make it non-dumpable so a child
2024
+ # cannot read that environment back via /proc/$PPID.
2025
+ hardening.make_non_dumpable()
2026
+ if argv is None:
2027
+ argv = sys.argv[1:]
2028
+ if argv and argv[0] == "setup":
2029
+ return _setup_main(argv[1:])
2030
+ if argv and argv[0] == "evidence":
2031
+ return _evidence_main(argv[1:])
2032
+ if argv and argv[0] == "acceptance":
2033
+ return _acceptance_main(
2034
+ argv[1:], providers_override=providers_override)
2035
+ if argv and argv[0] == "doctor":
2036
+ return _doctor_main(argv[1:],
2037
+ providers_override=providers_override,
2038
+ fallbacks_override=fallbacks_override)
2039
+ if argv and argv[0] == "record-verify":
2040
+ return _record_verify_main(argv[1:])
2041
+ if argv and argv[0] == "policy":
2042
+ return _policy_main(argv[1:])
2043
+ if argv and argv[0] == "sarif":
2044
+ return _sarif_main(argv[1:])
2045
+ if argv and argv[0] == "disputes":
2046
+ return _disputes_main(argv[1:])
2047
+ if argv and argv[0] == "locate":
2048
+ return _locate_main(argv[1:])
2049
+ if argv and argv[0] == "bench":
2050
+ return _bench_main(argv[1:],
2051
+ providers_override=providers_override)
2052
+ if argv and argv[0] == "pr":
2053
+ return _pr_main(argv[1:],
2054
+ providers_override=providers_override)
2055
+ if argv and argv[0] == "workflow":
2056
+ return _workflow_main(argv[1:],
2057
+ providers_override=providers_override)
2058
+ if argv and argv[0] == "loop":
2059
+ return _loop_main(argv[1:],
2060
+ providers_override=providers_override)
2061
+ if argv and argv[0] == "intake":
2062
+ return _intake_main(argv[1:],
2063
+ solvers_override=providers_override)
2064
+ if argv and argv[0] == "mcp":
2065
+ return _mcp_main(argv[1:])
2066
+ if argv and argv[0] == "support-bundle":
2067
+ from . import support
2068
+ return support.support_main(argv[1:])
2069
+ if argv and argv[0] == "report":
2070
+ from . import feedback
2071
+ return feedback.report_main(argv[1:])
2072
+ if argv and argv[0] == "telemetry":
2073
+ from . import feedback
2074
+ return feedback.telemetry_main(argv[1:])
2075
+ ap = argparse.ArgumentParser(
2076
+ prog="codereview",
2077
+ description="Multi-model code review loop with a "
2078
+ "deterministic gate.",
2079
+ epilog="Subcommands (each has its own --help; running "
2080
+ "one replaces the review flags above): "
2081
+ "setup — first-run wizard: pick providers, keys "
2082
+ "and per-role models; "
2083
+ "doctor — probe the configured seats; "
2084
+ "policy — validate and print the effective "
2085
+ "project policy; "
2086
+ "record-verify — verify a stored run record "
2087
+ "offline; "
2088
+ "disputes — list and record rulings on disputed "
2089
+ "findings; "
2090
+ "bench — run the benchmark corpus and reports; "
2091
+ "intake — turn an issue report into a reviewed "
2092
+ "solution; "
2093
+ "pr — review a GitHub pull request in place; "
2094
+ "workflow — run a saved multi-step review "
2095
+ "workflow; "
2096
+ "loop — repeat review rounds until the gate "
2097
+ "passes; "
2098
+ "sarif — export a record's findings as SARIF; "
2099
+ "evidence — run an evidence config standalone; "
2100
+ "locate — resolve a cited quote to blob "
2101
+ "positions; "
2102
+ "acceptance — run the acceptance harness; "
2103
+ "mcp — serve the gate over the Model Context "
2104
+ "Protocol (stdio; run_gate / get_verdict); "
2105
+ "support-bundle — write one redacted zip to attach "
2106
+ "to a bug report (nothing is sent); "
2107
+ "report — send a redacted bug report after you "
2108
+ "confirm it; "
2109
+ "telemetry — opt in or out of the anonymous crash "
2110
+ "ping (off by default). If something goes wrong, "
2111
+ "see SECURITY.md and CONTRIBUTING.md.")
2112
+ ap.add_argument("--version", action="version",
2113
+ version=core.VERSION,
2114
+ help="print the codereview version and exit")
2115
+ src = ap.add_argument_group("change source (pick one)")
2116
+ src.add_argument("--patch", help="unified diff file")
2117
+ src.add_argument("--repo", help="git repository path (default: .)")
2118
+ src.add_argument("--range", dest="range",
2119
+ help="git diff range, e.g. main...HEAD")
2120
+ src.add_argument("--staged", action="store_true",
2121
+ help="review the staged diff")
2122
+ src.add_argument("--files", nargs="+",
2123
+ help="review these files in full (new files)")
2124
+ ap.add_argument("--provider", action="append", default=[],
2125
+ help="provider preset (openai, gemini, anthropic, "
2126
+ "deepseek, minimax, moonshot, nvidia, xai, "
2127
+ "mistral, openrouter; preset:model to pick "
2128
+ "the model) or name|type|base_url|model|"
2129
+ "key_env; repeatable. Default: "
2130
+ "$CODEREVIEW_PROVIDERS or your saved setup "
2131
+ "(codereview setup)")
2132
+ ap.add_argument("--context", default="",
2133
+ help="builder's change description (or @file)")
2134
+ ap.add_argument("--test-command", action="append", default=[],
2135
+ help="test command to run and include; repeatable")
2136
+ ap.add_argument("--evidence-config",
2137
+ help="evidence runner config (JSON; Epic C): "
2138
+ "configured test/lint/SAST commands and "
2139
+ "security adapters run in a sandboxed copy "
2140
+ "of the tree (scrubbed environment, no "
2141
+ "network by default), the hashed evidence "
2142
+ "record is stored in the report, and "
2143
+ "required evidence that fails BLOCKS the "
2144
+ "gate (unavailable required evidence is "
2145
+ "INCONCLUSIVE). The config's shape matches "
2146
+ "the Epic B policy file's [evidence] table")
2147
+ ap.add_argument("--out", default="codereview-out",
2148
+ help="output directory for the report (default: "
2149
+ "codereview-out)")
2150
+ ap.add_argument("--allow-major", action="store_true",
2151
+ help="only blockers block the gate (majors advisory)")
2152
+ ap.add_argument("--strict", action="store_true",
2153
+ help="degraded or truncated reviews are INCONCLUSIVE "
2154
+ "instead of passing")
2155
+ ap.add_argument("--gate-profile",
2156
+ choices=("strict", "balanced", "corroborated"),
2157
+ default=None,
2158
+ help="gate profile: 'strict' (the default — "
2159
+ "every blocking-severity finding blocks), "
2160
+ "'balanced' (policy B1: a blocking "
2161
+ "finding raised solely by the operations "
2162
+ "lens, with no correctness-lens or "
2163
+ "deterministic corroboration in the "
2164
+ "merged finding, is demoted to a visible "
2165
+ "minor), or 'corroborated' (policy B2: "
2166
+ "evidence-class blocking for every lens "
2167
+ "— a blocking finding needs deterministic, "
2168
+ "probe-confirmed, or cross-family-"
2169
+ "corroborated evidence, plus a validated "
2170
+ "quote and a concrete trigger; "
2171
+ "single-source findings become visible "
2172
+ "non-blocking unverified majors. Requires "
2173
+ "at least two distinct vendor families "
2174
+ "able to serve the correctness lens; "
2175
+ "single-family panels keep 'strict'. B2 "
2176
+ "is opt-in and unconfirmed — dev "
2177
+ "evidence only). Overrides the policy "
2178
+ "file's [gate] profile; the policy file "
2179
+ "overrides the default. The default "
2180
+ "stays 'strict'")
2181
+ ap.add_argument("--max-patch-chars", type=int, default=80000,
2182
+ help="per-batch diff budget (default 80000)")
2183
+ ap.add_argument("--no-file-context", action="store_true",
2184
+ help="do not attach post-change file contents "
2185
+ "to the reviewer prompts (default for "
2186
+ "repo reviews: the contents of each "
2187
+ "changed file — from the range target, "
2188
+ "the index, or the working tree — ride "
2189
+ "along as reference so definitions "
2190
+ "outside the diff hunks can be checked; "
2191
+ "capped per file and in total, with "
2192
+ "truncation marked in the text and "
2193
+ "listed in the record)")
2194
+ ap.add_argument("--no-agents-context", action="store_true",
2195
+ help="do not ingest AGENTS.md project "
2196
+ "guidance into the reviewer prompts "
2197
+ "(default for repo reviews: AGENTS.md at "
2198
+ "the repo root and nested along the "
2199
+ "changed paths is attached as advisory, "
2200
+ "untrusted context alongside — never "
2201
+ "instead of — .codereview.toml; its "
2202
+ "'## Code Review Rules' section is "
2203
+ "surfaced as rules the panel may cite; "
2204
+ "redacted, capped per file and in total "
2205
+ "with truncation recorded, provenance "
2206
+ "(path + sha256) written to the record)")
2207
+ ap.add_argument("--verify-previous",
2208
+ help="previous report JSON: run a verification round")
2209
+ ap.add_argument("--builder-response", default="",
2210
+ help="builder's response to prior findings (or @file)")
2211
+ ap.add_argument("--role", action="append", default=[],
2212
+ help="assign a model to a role: "
2213
+ "<role>=<provider-spec> with role one of "
2214
+ "correctness, operations, verifier, "
2215
+ "tech-lead, solver (e.g. "
2216
+ "tech-lead=deepseek:deepseek-reasoner, "
2217
+ "correctness=moonshot); repeatable. The "
2218
+ "role's seat joins the panel and the "
2219
+ "assignment is recorded in the report")
2220
+ ap.add_argument("--seat-meta", action="append", default=[],
2221
+ help="tag a seat for the record: name:family[:tier] "
2222
+ "(e.g. gemini:google:paid); repeatable")
2223
+ ap.add_argument("--fallback", action="append", default=[],
2224
+ help="fallback seat for a lens when its primary "
2225
+ "fails: lens=provider-spec (e.g. "
2226
+ "correctness=openai); repeatable")
2227
+ ap.add_argument("--fixture-manifest",
2228
+ help="versioned fixture manifest (JSON): the "
2229
+ "enumerated paths are intentionally "
2230
+ "vulnerable test DATA — their findings are "
2231
+ "classified in the record as expected "
2232
+ "fixture findings instead of blocking the "
2233
+ "gate. The manifest's hash, policy version "
2234
+ "and paths are recorded and recomputed by "
2235
+ "record-verify")
2236
+ ap.add_argument("--precedents",
2237
+ help="content-addressed precedent store "
2238
+ "(JSON): findings already adjudicated "
2239
+ "whose cited file is UNCHANGED (git blob "
2240
+ "identical to the disposition) are "
2241
+ "classified in the record as standing "
2242
+ "findings linked to their disposition "
2243
+ "instead of blocking again. The store's "
2244
+ "hash and every link are recorded and "
2245
+ "recomputed by record-verify")
2246
+ ap.add_argument("--policy",
2247
+ help="project policy file (TOML, Epic B): the "
2248
+ "panel, evidence, path and waiver rules "
2249
+ "the gate enforces for this repository. "
2250
+ "Default: .codereview.toml at the repo "
2251
+ "root, when present. An invalid policy "
2252
+ "file stops the run — it is never "
2253
+ "silently ignored")
2254
+ ap.add_argument("--trust-repo-policy", action="store_true",
2255
+ help="honour a policy DISCOVERED in the "
2256
+ "reviewed repository in full, including "
2257
+ "its [hooks] and gate-weakening keys. "
2258
+ "Without this flag a discovered policy "
2259
+ "keeps only its strengthening keys: "
2260
+ "hooks, a weaker-than-strict gate "
2261
+ "profile, advisory path rules, waiver "
2262
+ "leniency, diversity degrade/exclusion "
2263
+ "and risk tiering are ignored and named "
2264
+ "in the record's policy.ignored_keys "
2265
+ "(P3-01 trust restriction). A policy "
2266
+ "named with --policy is always honoured "
2267
+ "in full")
2268
+ ap.add_argument("--tech-lead",
2269
+ help="designate the seat that serves as Tech "
2270
+ "Lead for adjudicating refuted findings "
2271
+ "(owner amendment 2026-11-04): the Tech "
2272
+ "Lead's family must be independent of "
2273
+ "both the finding's raiser and its "
2274
+ "refuter — a designation that cannot "
2275
+ "serve independently is rejected and "
2276
+ "substituted, loudly, on the record. "
2277
+ "Without a designation, the first "
2278
+ "independent seat serves")
2279
+ ap.add_argument("--tech-lead-shadow", action="store_true",
2280
+ help="run the Tech Lead in SHADOW mode: for "
2281
+ "every disputed finding the full "
2282
+ "structured adjudication is produced and "
2283
+ "recorded in the record's "
2284
+ "shadow_adjudications section — blinded "
2285
+ "A/B positions, double-drawn for majors "
2286
+ "— but the decisions are non-binding and "
2287
+ "never change the gate verdict. The seat "
2288
+ "is the --tech-lead seat when designated, "
2289
+ "else an independent seat, else any "
2290
+ "served seat under reduced independence. "
2291
+ "Equivalent to the CODEREVIEW_TECH_LEAD_"
2292
+ "SHADOW=1 environment variable")
2293
+ ap.add_argument("--attempts-log",
2294
+ help="append one JSON line describing this run "
2295
+ "(seats, error classes, gate) to this file. "
2296
+ "Trust level: operator-supplied — the path "
2297
+ "comes from the command line only, never "
2298
+ "from repo content, policy, or config files "
2299
+ "(same trust as --out)")
2300
+ ap.add_argument("--private", action="store_true",
2301
+ help="the repository is private: seats tagged "
2302
+ "with a free tier are refused (free tiers "
2303
+ "may train on submitted code)")
2304
+ ap.add_argument("--timeout", type=int, default=None,
2305
+ help="per-seat timeout in seconds for agent "
2306
+ "seats (forwarded to provider construction "
2307
+ "for primary AND fallback seats, like "
2308
+ "doctor/intake); also the wait bound for "
2309
+ "Tech Lead adjudication calls")
2310
+ ap.add_argument("--probe-timeout", type=int, default=120,
2311
+ help="wall-clock bound in seconds for one "
2312
+ "harness probe (default 120)")
2313
+ ap.add_argument("--no-probe-harness", action="store_true",
2314
+ help="do not wire the probe harness: a Tech "
2315
+ "Lead probe request then escalates for "
2316
+ "lack of a channel, as before the "
2317
+ "harness existed")
2318
+ args = ap.parse_args(argv)
2319
+
2320
+ if args.timeout is not None and args.timeout <= 0:
2321
+ print("codereview: --timeout must be a positive number "
2322
+ "of seconds", file=sys.stderr)
2323
+ return 2
2324
+ if _refuse_unwritable_out(args.out, "codereview"):
2325
+ return 2
2326
+
2327
+ # P3-09: an explicitly given --repo must be a real
2328
+ # directory. "" used to collapse to "." via `or`, and a
2329
+ # nonexistent path was accepted in BOTH modes — in patch
2330
+ # mode the run then silently skipped all project context
2331
+ # (policy, AGENTS.md, evidence) and still passed.
2332
+ if args.repo is not None:
2333
+ if args.repo == "":
2334
+ print("codereview: --repo must not be empty "
2335
+ "(omit it to review the current directory)",
2336
+ file=sys.stderr)
2337
+ return 2
2338
+ if not os.path.isdir(args.repo):
2339
+ print(f"codereview: --repo {args.repo!r} is not "
2340
+ f"an existing directory", file=sys.stderr)
2341
+ return 2
2342
+
2343
+ def _maybe_file(text):
2344
+ if text.startswith("@"):
2345
+ with open(os.path.expanduser(text[1:]), encoding="utf-8",
2346
+ errors="replace") as fh:
2347
+ return fh.read()
2348
+ return text
2349
+
2350
+ try:
2351
+ patch = _load_patch(args)
2352
+ except Exception as exc: # noqa: BLE001
2353
+ print(f"codereview: could not load the change: {exc}",
2354
+ file=sys.stderr)
2355
+ return 2
2356
+ fixture_scope = None
2357
+ if args.fixture_manifest:
2358
+ try:
2359
+ fixture_scope = _load_fixture_scope(
2360
+ args.fixture_manifest, repo=args.repo or ".")
2361
+ except Exception as exc: # noqa: BLE001
2362
+ print(f"codereview: could not load the fixture "
2363
+ f"manifest: {exc}", file=sys.stderr)
2364
+ return 2
2365
+ print(f"codereview: fixture scope: "
2366
+ f"{len(fixture_scope['patterns'])} path(s) from "
2367
+ f"{fixture_scope['manifest_path']} (policy "
2368
+ f"v{fixture_scope['policy_version']}, sha256 "
2369
+ f"{fixture_scope['manifest_sha256'][:12]}…) — "
2370
+ f"fixture findings will be classified separately, "
2371
+ f"not dropped", file=sys.stderr)
2372
+ precedents = None
2373
+ if args.precedents:
2374
+ try:
2375
+ precedents = _load_precedents(
2376
+ args.precedents, repo=args.repo or ".")
2377
+ except Exception as exc: # noqa: BLE001
2378
+ print(f"codereview: could not load the precedent "
2379
+ f"store: {exc}", file=sys.stderr)
2380
+ return 2
2381
+ print(f"codereview: precedent store: "
2382
+ f"{len(precedents['entries'])} entr(ies) from "
2383
+ f"{precedents['store_path']} (sha256 "
2384
+ f"{precedents['store_sha256'][:12]}…) — standing "
2385
+ f"findings will be classified separately, not "
2386
+ f"dropped", file=sys.stderr)
2387
+ project_policy = None
2388
+ policy_path = args.policy
2389
+ # P3-01: a policy DISCOVERED in the reviewed repo is
2390
+ # repo-supplied configuration. The origin is stamped on
2391
+ # the policy, lands in the record's policy section, and a
2392
+ # discovered policy warns on stderr. And since the owner's
2393
+ # 2026-11-06 ruling (option 1), a discovered policy is no
2394
+ # longer applied in full: unless the operator passes
2395
+ # --trust-repo-policy, the restriction in policy.py drops
2396
+ # its hooks and gate-weakening keys back to defaults HERE,
2397
+ # at load — before the hook runner is built from the
2398
+ # policy below and before the gate reads it — and names
2399
+ # every dropped key on the record (policy.ignored_keys).
2400
+ policy_origin = "explicit" if policy_path is not None \
2401
+ else None
2402
+ if policy_path is None:
2403
+ discovered = policy_mod.find_policy(args.repo or ".")
2404
+ if discovered:
2405
+ policy_path = discovered
2406
+ policy_origin = "discovered-in-repo"
2407
+ if policy_path:
2408
+ try:
2409
+ project_policy = policy_mod.load_policy(policy_path)
2410
+ except Exception as exc: # noqa: BLE001
2411
+ print(f"codereview: could not load the project "
2412
+ f"policy: {exc}", file=sys.stderr)
2413
+ return 2
2414
+ project_policy["origin"] = policy_origin
2415
+ ignored_keys = []
2416
+ if policy_origin == "discovered-in-repo" \
2417
+ and not args.trust_repo_policy:
2418
+ ignored_keys = policy_mod \
2419
+ .apply_discovered_restrictions(project_policy)
2420
+ print(f"codereview: project policy: "
2421
+ f"{len(project_policy['paths'])} path rule(s) from "
2422
+ f"{policy_path} — the gate enforces it and the "
2423
+ f"record lists every rule applied",
2424
+ file=sys.stderr)
2425
+ if policy_origin == "discovered-in-repo":
2426
+ print(f"codereview: WARNING: that policy was "
2427
+ f"discovered in the reviewed repository "
2428
+ f"({policy_path}) — it comes from the tree "
2429
+ f"under review, not from the operator; the "
2430
+ f"record's policy section states this origin",
2431
+ file=sys.stderr)
2432
+ if ignored_keys:
2433
+ print(f"codereview: WARNING: "
2434
+ f"{len(ignored_keys)} gate-weakening "
2435
+ f"key(s) in that policy were ignored "
2436
+ f"under the discovered-policy trust "
2437
+ f"restriction ({', '.join(ignored_keys)}) "
2438
+ f"— pass --trust-repo-policy to honour "
2439
+ f"the repository's policy in full",
2440
+ file=sys.stderr)
2441
+ # Epic L3: lifecycle hooks from the policy's [hooks] table.
2442
+ # The runner is created here (no side effects) and fired at
2443
+ # the lifecycle points below; with no [hooks] commands it
2444
+ # stays None and the run is exactly as before. Hooks run
2445
+ # sandboxed (Epic C) and can never change the verdict.
2446
+ hook_runner = None
2447
+ if project_policy is not None:
2448
+ _hooks_config = hooks_mod.hooks_from_policy(
2449
+ project_policy)
2450
+ if _hooks_config is not None:
2451
+ hook_runner = hooks_mod.HookRunner(
2452
+ _hooks_config, args.repo or ".")
2453
+ print(f"codereview: lifecycle hooks: "
2454
+ f"{', '.join(hook_runner.configured_events())} "
2455
+ f"configured in {policy_path} — sandboxed, "
2456
+ f"recorded in the record's hooks section, "
2457
+ f"never gate-changing", file=sys.stderr)
2458
+ if not patch.strip():
2459
+ print("codereview: the change is empty — nothing to review.",
2460
+ file=sys.stderr)
2461
+ return 2
2462
+
2463
+ # Epic K3a hosting state for this run (set in the specs
2464
+ # branch below): an active HerdrHost plus the config-marked
2465
+ # seat names, or the inert reason the record carries as one
2466
+ # `herdr_hosting` line. Override providers (bench, internal
2467
+ # runner) are never hosted.
2468
+ herdr_host = None
2469
+ herdr_seats = None
2470
+ herdr_inert = None
2471
+ if providers_override is not None:
2472
+ providers = providers_override
2473
+ else:
2474
+ specs = args.provider
2475
+ if not specs:
2476
+ env = (os.environ.get("CODEREVIEW_PROVIDERS") or os.environ.get("REVIEWLOOP_PROVIDERS", ""))
2477
+ try:
2478
+ specs = [s.strip() for s in env.split(",")
2479
+ if s.strip()] \
2480
+ or userconfig.default_provider_specs()
2481
+ except ValueError as exc:
2482
+ print(f"codereview: {exc}", file=sys.stderr)
2483
+ return 2
2484
+ if not specs and _stdin_is_tty():
2485
+ # First open in a terminal: run the setup wizard,
2486
+ # then use what the user just saved as their setup.
2487
+ # An aborted wizard (EOF / Ctrl-C) is a clean stop,
2488
+ # never a traceback: fall through to not-set-up.
2489
+ try:
2490
+ _run_setup_wizard()
2491
+ specs = userconfig.default_provider_specs()
2492
+ except (ValueError, EOFError,
2493
+ KeyboardInterrupt) as exc:
2494
+ print(f"codereview: setup did not complete "
2495
+ f"({exc}); nothing was run.",
2496
+ file=sys.stderr)
2497
+ return 2
2498
+ if not specs:
2499
+ print(userconfig.NOT_SET_UP, file=sys.stderr)
2500
+ return 2
2501
+ # Epic K3a: seats the saved config marks for herdr
2502
+ # hosting ([hosting] herdr = [...]) are detected ONCE
2503
+ # for the run. An active detection hosts those seats;
2504
+ # an inert one leaves every seat headless and puts ONE
2505
+ # line in the record (record_extra below) — herdr is
2506
+ # never a hard dependency.
2507
+ try:
2508
+ herdr_seats = userconfig.default_hosting_herdr_seats()
2509
+ except ValueError as exc:
2510
+ print(f"codereview: {exc}", file=sys.stderr)
2511
+ return 2
2512
+ if herdr_seats:
2513
+ plan = herdrhost.detect()
2514
+ if plan["active"]:
2515
+ herdr_host = plan["host"]
2516
+ else:
2517
+ herdr_inert = herdrhost.inert_line(plan)
2518
+ herdr_seats = None
2519
+ try:
2520
+ providers = build_providers(
2521
+ specs, agent_timeout=args.timeout,
2522
+ herdr_host=herdr_host, herdr_seats=herdr_seats)
2523
+ except Exception as exc: # noqa: BLE001
2524
+ print(f"codereview: provider setup failed: {exc}",
2525
+ file=sys.stderr)
2526
+ return 2
2527
+
2528
+ lens_seats, seat_roles, role_models, tech_lead_seat = {}, {}, {}, None
2529
+ try:
2530
+ _config_roles = userconfig.default_role_assignments()
2531
+ except ValueError as exc:
2532
+ print(f"codereview: {exc}", file=sys.stderr)
2533
+ return 2
2534
+ if args.role or _config_roles:
2535
+ try:
2536
+ _cli_roles = registry.parse_role_assignments(args.role)
2537
+ except ValueError as exc:
2538
+ print(f"codereview: {exc}", file=sys.stderr)
2539
+ return 2
2540
+ _merged_roles = {**_config_roles, **_cli_roles}
2541
+ try:
2542
+ lens_seats, seat_roles, role_models, tech_lead_seat = \
2543
+ _apply_role_assignments(
2544
+ providers,
2545
+ [f"{r}={spec}" for r, spec
2546
+ in _merged_roles.items()],
2547
+ agent_timeout=args.timeout,
2548
+ herdr_host=herdr_host,
2549
+ herdr_seats=herdr_seats)
2550
+ except Exception as exc: # noqa: BLE001
2551
+ print(f"codereview: role assignment failed: {exc}",
2552
+ file=sys.stderr)
2553
+ return 2
2554
+
2555
+ # Seat metadata precedence: the derived baseline, then
2556
+ # the saved setup's [seat_meta] (P6-06 — the families the
2557
+ # wizard computed for this panel), then explicit
2558
+ # --seat-meta, then the caller's override below.
2559
+ try:
2560
+ saved_meta = userconfig.default_seat_meta()
2561
+ except ValueError as exc:
2562
+ print(f"codereview: {exc}", file=sys.stderr)
2563
+ return 2
2564
+ baseline_meta = _default_seat_meta(providers)
2565
+ for _name, _family in saved_meta.items():
2566
+ if _name in baseline_meta and _family:
2567
+ baseline_meta[_name] = {
2568
+ "family": _family,
2569
+ "tier": baseline_meta[_name].get(
2570
+ "tier", "unknown")}
2571
+ try:
2572
+ seat_meta = _parse_seat_meta(
2573
+ args.seat_meta, baseline_meta)
2574
+ except ValueError as exc:
2575
+ print(f"codereview: {exc}", file=sys.stderr)
2576
+ return 2
2577
+ if seat_meta_override:
2578
+ seat_meta.update(seat_meta_override)
2579
+ violations = core.check_tier_policy(seat_meta,
2580
+ private=args.private)
2581
+ if violations:
2582
+ for violation in violations:
2583
+ print(f"codereview: tier policy: {violation}",
2584
+ file=sys.stderr)
2585
+ return 2
2586
+
2587
+ fallbacks = fallbacks_override
2588
+ if fallbacks is None and args.fallback:
2589
+ fallbacks = {}
2590
+ for spec in args.fallback:
2591
+ lens, eq, pspec = spec.partition("=")
2592
+ lens = lens.strip()
2593
+ if not eq or lens not in core.LENSES or not pspec.strip():
2594
+ print(f"codereview: bad --fallback {spec!r}: want "
2595
+ f"lens=provider-spec with lens one of "
2596
+ f"{sorted(core.LENSES)}", file=sys.stderr)
2597
+ return 2
2598
+ try:
2599
+ built = build_providers(
2600
+ [pspec.strip()], agent_timeout=args.timeout)
2601
+ except Exception as exc: # noqa: BLE001
2602
+ print(f"codereview: fallback setup failed: {exc}",
2603
+ file=sys.stderr)
2604
+ return 2
2605
+ (fb_name, fb_fn), = built.items()
2606
+ if fb_name in providers:
2607
+ print(f"codereview: fallback seat {fb_name!r} is "
2608
+ f"already a primary seat", file=sys.stderr)
2609
+ return 2
2610
+ fallbacks[lens] = (fb_name, fb_fn)
2611
+
2612
+ record_extra = None
2613
+ if args.range:
2614
+ record_extra = {"range": args.range}
2615
+ try:
2616
+ record_extra["head"] = core._git(
2617
+ args.repo or ".", "rev-parse", "HEAD")
2618
+ except Exception: # noqa: BLE001 — head is best-effort
2619
+ pass
2620
+ if seat_roles:
2621
+ # Which model served which role is part of the record
2622
+ # (owner directive 2026-11-04): seat name, provider,
2623
+ # model, and family for every assigned role.
2624
+ record_extra = {**(record_extra or {}),
2625
+ "seat_roles": seat_roles,
2626
+ "role_models": role_models}
2627
+ if herdr_inert:
2628
+ # Epic K3a: hosting was configured but the adapter
2629
+ # stayed inert — exactly one line in the record says
2630
+ # why; the seats all ran headless.
2631
+ record_extra = {**(record_extra or {}),
2632
+ "herdr_hosting": herdr_inert}
2633
+
2634
+
2635
+ cwd = args.repo or "."
2636
+ test_results = _run_tests(args.test_command, cwd)
2637
+
2638
+ review_kwargs = {}
2639
+ if not args.no_probe_harness:
2640
+ # Probe harness (refutation-process solution §5.2):
2641
+ # the Tech Lead's probe channel, wired by default so a
2642
+ # needs_probe decision no longer auto-escalates for
2643
+ # lack of a channel. Probes execute in the harness
2644
+ # sandbox against a temp copy of the reviewed tree —
2645
+ # never in the tree itself, never with the process
2646
+ # environment. The swarm internal runner reaches this
2647
+ # same wiring through cli.main.
2648
+ from . import probe as probe_mod
2649
+ review_kwargs["probe_fn"] = probe_mod.make_probe_fn(
2650
+ args.repo or ".", timeout=args.probe_timeout)
2651
+ if lens_seats:
2652
+ review_kwargs["lens_seats"] = lens_seats
2653
+ if tech_lead_seat and "tech_lead" in \
2654
+ inspect.signature(core.run_review).parameters:
2655
+ # Tech Lead designation seam: when the engine carries the
2656
+ # adjudication role (step4 design), hand the designated
2657
+ # seat over — the engine itself enforces the independence
2658
+ # rule (its family must differ from raiser and refuter)
2659
+ # and substitutes loudly when the designee is ineligible.
2660
+ # Until the seam lands, the assignment travels in the
2661
+ # record's seat_roles only.
2662
+ review_kwargs["tech_lead"] = tech_lead_seat
2663
+ if args.tech_lead:
2664
+ # The explicit --tech-lead flag designates the same role a
2665
+ # --role/saved-setup assignment can; the command line wins
2666
+ # (the same precedence --role has over the saved setup).
2667
+ review_kwargs["tech_lead"] = args.tech_lead
2668
+ if args.evidence_config:
2669
+ # Epic C: run the evidence config against a sandboxed
2670
+ # copy of the tree and attach the record to the review;
2671
+ # run_review stores it in the report and applies the C3
2672
+ # gate rule. Verification rounds keep their own gate
2673
+ # semantics and do not take evidence (review_kwargs only
2674
+ # reaches run_review).
2675
+ try:
2676
+ evidence_cfg = evidence_mod.load_config(
2677
+ args.evidence_config)
2678
+ except ValueError as exc:
2679
+ print(f"codereview: {exc}", file=sys.stderr)
2680
+ return 2
2681
+ review_kwargs["evidence"] = evidence_mod.collect(
2682
+ evidence_cfg, cwd)
2683
+ shadow_role_seat = seat_roles.get("tech-lead-shadow")
2684
+ if args.tech_lead_shadow or shadow_role_seat or os.environ.get(
2685
+ "CODEREVIEW_TECH_LEAD_SHADOW") == "1":
2686
+ # Shadow Tech Lead: recorded, non-binding, gate-neutral
2687
+ # (the engine guarantees verdict invariance). Seat
2688
+ # precedence: an explicit tech-lead-shadow role, then
2689
+ # the binding --tech-lead designation, then the engine's
2690
+ # own independent-seat choice.
2691
+ review_kwargs["tech_lead_shadow"] = True
2692
+ shadow_seat = shadow_role_seat or args.tech_lead \
2693
+ or tech_lead_seat
2694
+ if shadow_seat:
2695
+ review_kwargs["tech_lead_shadow_seat"] = shadow_seat
2696
+
2697
+ file_context = None
2698
+ if not args.verify_previous and not args.patch \
2699
+ and not args.files and not args.no_file_context:
2700
+ # Acceptance D1: attach the post-change contents of the
2701
+ # changed files so reviewers can check definitions the
2702
+ # hunks reference but do not contain. A collection
2703
+ # problem degrades to the old diff-only behaviour with
2704
+ # the reason on stderr — context is an aid, never a
2705
+ # reason to fail a review.
2706
+ try:
2707
+ file_context, fc_entries = core.collect_file_context(
2708
+ args.repo or ".", patch, range_spec=args.range,
2709
+ staged=args.staged)
2710
+ except Exception as exc: # noqa: BLE001
2711
+ print(f"codereview: file context unavailable "
2712
+ f"({exc}); reviewing the diff alone",
2713
+ file=sys.stderr)
2714
+ file_context = None
2715
+ if file_context is not None:
2716
+ attached = [e for e in fc_entries
2717
+ if e.get("sent_chars")]
2718
+ skipped = [e for e in fc_entries if e.get("skipped")]
2719
+ notes = []
2720
+ for e in fc_entries:
2721
+ if e.get("truncated"):
2722
+ notes.append(
2723
+ f"{e['file']} truncated "
2724
+ f"({e['truncated']})")
2725
+ print(f"codereview: file context: "
2726
+ f"{len(attached)} file(s) attached "
2727
+ f"({sum(e['sent_chars'] for e in attached)} "
2728
+ f"chars), {len(skipped)} skipped"
2729
+ + (f"; {'; '.join(notes)}" if notes else ""),
2730
+ file=sys.stderr)
2731
+ if not file_context:
2732
+ file_context = None
2733
+
2734
+ agents_context = None
2735
+ if not args.verify_previous and not args.patch \
2736
+ and not args.files and not args.no_agents_context:
2737
+ # Epic L1: ingest the repository's AGENTS.md guidance
2738
+ # (root + nested along the changed paths) as advisory
2739
+ # reviewer context. The files are untrusted: the
2740
+ # collector redacts them, run_review delimits them as
2741
+ # data in the prompt, and their provenance lands in the
2742
+ # record — they never touch the policy, the panel, or
2743
+ # the gate. A collection problem degrades to no guidance
2744
+ # with the reason on stderr, exactly like file context.
2745
+ try:
2746
+ agents_context, ag_entries = \
2747
+ agentsmd.collect_agents_context(
2748
+ args.repo or ".", patch)
2749
+ except Exception as exc: # noqa: BLE001
2750
+ print(f"codereview: AGENTS.md context unavailable "
2751
+ f"({exc}); reviewing without project guidance",
2752
+ file=sys.stderr)
2753
+ agents_context = None
2754
+ if agents_context is not None:
2755
+ skipped = [e for e in ag_entries if e.get("skipped")]
2756
+ print(f"codereview: AGENTS.md context: "
2757
+ f"{len(agents_context['files'])} file(s) "
2758
+ f"ingested ({agents_context['total_chars']} "
2759
+ f"chars), {len(skipped)} skipped"
2760
+ + ("; truncation recorded"
2761
+ if agents_context["truncated"] else ""),
2762
+ file=sys.stderr)
2763
+
2764
+ # Epic L4: attention notifications resolve from the USER
2765
+ # SETUP's [notify] table only — never the project policy
2766
+ # (notifications are operator preference, not project
2767
+ # contract). Unset means no Notifier at all, and the run
2768
+ # behaves exactly as before this epic.
2769
+ notify = notify_mod.notifier_for(
2770
+ userconfig.default_notify_command())
2771
+
2772
+ if hook_runner is not None:
2773
+ # Epic L3 pre-run: every setup step has succeeded and
2774
+ # the run is committed — the next thing that happens is
2775
+ # the round's first seat call.
2776
+ hook_runner.fire("pre-run")
2777
+
2778
+ if args.verify_previous:
2779
+ try:
2780
+ with open(args.verify_previous, encoding="utf-8") as fh:
2781
+ previous = json.load(fh)
2782
+ except Exception as exc: # noqa: BLE001
2783
+ print(f"codereview: could not read previous report: {exc}",
2784
+ file=sys.stderr)
2785
+ return 2
2786
+ builder_response = _maybe_file(args.builder_response)
2787
+ report = core.run_verification(
2788
+ patch, providers, previous,
2789
+ builder_response,
2790
+ test_results=test_results,
2791
+ max_patch_chars=args.max_patch_chars,
2792
+ allow_major=args.allow_major, strict=args.strict,
2793
+ seat_meta=seat_meta, fallbacks=fallbacks,
2794
+ record_extra=record_extra,
2795
+ private=args.private, progress=_seat_progress,
2796
+ notify=notify)
2797
+ # Epic C4: verify the builder report's claimed commands
2798
+ # against the tree and record the outcome in the run
2799
+ # record. This runs after the gate has been decided and
2800
+ # nothing reads the section back — claim verification
2801
+ # is evidence, not a gate input: a mismatch downgrades
2802
+ # the claim, never the verdict (claims.BOUNDARY).
2803
+ claim_section = claims_mod.verify_report_claims(
2804
+ builder_response, args.repo or ".")
2805
+ if claim_section is not None:
2806
+ report["claim_verification"] = claim_section
2807
+ else:
2808
+ report = core.run_review(
2809
+ patch, providers, context=_maybe_file(args.context),
2810
+ test_results=test_results,
2811
+ max_patch_chars=args.max_patch_chars,
2812
+ allow_major=args.allow_major, strict=args.strict,
2813
+ seat_meta=seat_meta, fallbacks=fallbacks,
2814
+ record_extra=record_extra,
2815
+ fixture_scope=fixture_scope, private=args.private,
2816
+ precedents=precedents, policy=project_policy,
2817
+ gate_profile=args.gate_profile,
2818
+ file_context=file_context, progress=_seat_progress,
2819
+ agents_context=agents_context, notify=notify,
2820
+ tech_lead_timeout=args.timeout, **review_kwargs)
2821
+
2822
+ if policy_origin == "discovered-in-repo" \
2823
+ and not args.verify_previous and not args.range:
2824
+ # P3-01(c), working-tree half: `git diff` never shows
2825
+ # an untracked or modified .codereview.toml as a patch
2826
+ # section, so run_review's section-based
2827
+ # policy_self_modification flag cannot see the common
2828
+ # case — the applied policy file is itself part of the
2829
+ # uncommitted change. Ask git about the file directly.
2830
+ try:
2831
+ _pol_status = core._git(
2832
+ args.repo or ".", "status", "--porcelain",
2833
+ "--", policy_mod.POLICY_FILENAME)
2834
+ except Exception: # noqa: BLE001 — the flag is
2835
+ # best-effort observability, never a run failure.
2836
+ _pol_status = ""
2837
+ if _pol_status.strip():
2838
+ _touched = report.setdefault(
2839
+ "policy_self_modification", [])
2840
+ if policy_mod.POLICY_FILENAME not in _touched:
2841
+ report["policy_self_modification"] = sorted(
2842
+ _touched + [policy_mod.POLICY_FILENAME])
2843
+
2844
+ if hook_runner is not None:
2845
+ # Epic L3: the blocked trigger is a fact of the finished
2846
+ # round; the record is written WITH the hooks section
2847
+ # (configured events, trigger, pre-run result) so the
2848
+ # post-record hooks are handed a file that already
2849
+ # carries all of it.
2850
+ hook_runner.blocked_trigger = \
2851
+ hooks_mod.first_blocked_trigger(
2852
+ report.get("reviewer_reports"))
2853
+ report["hooks"] = hook_runner.section()
2854
+ try:
2855
+ jpath, mpath = _write_report(report, args.out, "codereview")
2856
+ except _ReportWriteFailed:
2857
+ return 2
2858
+ if hook_runner is not None:
2859
+ # The record now exists: on-blocked (only when a seat
2860
+ # blocked) and post-verdict fire with its path. Their
2861
+ # results are appended to the hooks section and the
2862
+ # record is rewritten once at the same path, so the
2863
+ # file the hooks received is the file carrying the
2864
+ # complete hooks ledger.
2865
+ if hook_runner.blocked_trigger is not None:
2866
+ hook_runner.fire("on-blocked", record_path=jpath)
2867
+ hook_runner.fire("post-verdict", record_path=jpath)
2868
+ report["hooks"] = hook_runner.section()
2869
+ try:
2870
+ jpath, mpath = _write_report(
2871
+ report, args.out, "codereview",
2872
+ stem=os.path.basename(jpath)[:-len(".json")])
2873
+ except _ReportWriteFailed:
2874
+ return 2
2875
+ if args.attempts_log:
2876
+ try:
2877
+ entry = {
2878
+ "ts": core._now_iso(), "mode": report.get("mode"),
2879
+ "gate": report.get("gate"),
2880
+ "degraded": report.get("degraded"),
2881
+ "range": args.range,
2882
+ "patch_sha256": report.get("patch_sha256"),
2883
+ "seats": [{
2884
+ "provider": r.get("provider"),
2885
+ "lens": r.get("lens"),
2886
+ "status": r.get("status"),
2887
+ "family": r.get("family"),
2888
+ "tier": r.get("tier"),
2889
+ "fallback_for": r.get("fallback_for"),
2890
+ "attempts": r.get("attempts"),
2891
+ "error_class": (r.get("error_classes")
2892
+ or [""])[-1],
2893
+ } for r in report.get("reviewer_reports") or []],
2894
+ "tests_ok": (all(t.get("ok") for t in test_results)
2895
+ if test_results else None),
2896
+ }
2897
+ with open(args.attempts_log, "a",
2898
+ encoding="utf-8") as fh:
2899
+ fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
2900
+ except Exception: # noqa: BLE001 — bookkeeping never breaks
2901
+ pass
2902
+ n_block = sum(1 for f in report.get("findings") or []
2903
+ if f["severity"] in ("blocker", "major"))
2904
+ print(f"codereview: gate = {report['gate'].upper()} "
2905
+ f"({'; '.join(report.get('gate_reasons') or [])})")
2906
+ print(f" findings: {len(report.get('findings') or [])} "
2907
+ f"({n_block} blocking-severity)")
2908
+ open_disputes = report.get("open_disputes") or {}
2909
+ if open_disputes.get("count"):
2910
+ print(f" open disputes: {open_disputes['count']} "
2911
+ f"({', '.join(open_disputes.get('ids') or [])}) "
2912
+ f"— see `codereview disputes`")
2913
+ print(f" report: {mpath}")
2914
+ print(f" data: {jpath}")
2915
+ return {"pass": 0, "blocked": 1, "inconclusive": 2}.get(
2916
+ report["gate"], 2)
2917
+
2918
+
2919
+ def console_main():
2920
+ """The installed `codereview` / `reviewloop` entry point: the
2921
+ CLI under the crash policy (crash.run_guarded). `main` itself
2922
+ is unchanged so library callers and tests still see exceptions."""
2923
+ return crash_mod.run_guarded(main)
2924
+
2925
+
2926
+ if __name__ == "__main__":
2927
+ raise SystemExit(console_main())