Coverage for src/ai_jury/cli.py: 99%

830 statements  

« prev     ^ index     » next       coverage.py v7.15.4, created at 2026-08-17 21:31 +0000

1"""Command-line entry point: ``jury``. 

2 

3Examples: 

4 jury --pr 123 # review a GitHub PR 

5 jury --pr 123 --post # ...and post the verdict as a comment 

6 jury --diff-file changes.diff # review a local diff file 

7 jury --diff-file - # read a diff from stdin 

8 jury --mock # offline pipeline demo (no live CLIs) 

9 jury --doctor # local readiness diagnostics 

10 jury --config-validate # validate jury.toml and exit 

11""" 

12 

13from __future__ import annotations 

14 

15import argparse 

16import contextlib 

17import io 

18import json 

19import sys 

20from pathlib import Path 

21 

22from . import __version__ 

23from . import doctor as doctor_module 

24from .ci import evaluate_ci 

25from .classification import classify, label_strings 

26from .config import ConfigError, load_config, load_raw_config, validate_config 

27from .github import ( 

28 apply_labels, 

29 issue_body, 

30 post_inline_comments, 

31 post_issue_comment, 

32 post_pr_comment, 

33 pr_context, 

34 pr_diff, 

35) 

36from .metadata import build_run_metadata 

37from .orchestrator import review_diff, run_jury 

38from .policy import PolicyError, load_policy 

39from .redaction import redact 

40from .report import render, render_live_step, render_transcript 

41 

42# Hard ceiling on raw diff ingestion. The per-run ``diff.max_bytes`` budget is 

43# only applied *after* the full diff is read and split, so an unbounded 

44# ``stdin``/``--diff-file`` read could OOM the process before that cap engages 

45# (security audit 2026-06-13). This ceiling sits far above any realistic review 

46# budget; it exists solely to bound memory against a hostile/huge input. 

47_MAX_DIFF_INGEST_BYTES = 64 * 1024 * 1024 # 64 MiB 

48 

49 

50def _read_capped(fh, source: str) -> str: 

51 """Read from ``fh``, refusing inputs above the ingest ceiling. 

52 

53 The cap is enforced on **bytes**, not characters: a text read of N chars can 

54 hold up to 4N bytes for multi-byte UTF-8, so a char ceiling would admit 

55 several times the intended memory (security audit 2026-06-13, red-team). 

56 Callers pass a binary stream for real input (``sys.stdin.buffer`` / a file 

57 opened ``"rb"``); a text stream is also accepted (its read is measured by its 

58 UTF-8 byte length) so test doubles and unusual streams still work. 

59 """ 

60 data = fh.read(_MAX_DIFF_INGEST_BYTES + 1) 

61 if isinstance(data, str): 

62 if len(data.encode("utf-8", "replace")) > _MAX_DIFF_INGEST_BYTES: 

63 raise SystemExit( 

64 f"error: {source} exceeds the {_MAX_DIFF_INGEST_BYTES}-byte ingest limit" 

65 ) 

66 return data 

67 if len(data) > _MAX_DIFF_INGEST_BYTES: 

68 raise SystemExit( 

69 f"error: {source} exceeds the {_MAX_DIFF_INGEST_BYTES}-byte ingest limit" 

70 ) 

71 return data.decode("utf-8", errors="replace") 

72 

73 

74def _checked_revision(value: str, flag: str) -> str: 

75 """Reject a revision that cannot safely reach ``git``'s argv (issue #367). 

76 

77 ``run`` uses argv, never a shell, so quoting is not the risk — a value starting 

78 with ``-`` is: git would read it as an option rather than a revision. Refused 

79 rather than escaped, and ``--`` is passed at the call site as a second guard. 

80 Empty is refused too, since it would silently widen the diff. 

81 """ 

82 revision = (value or "").strip() 

83 if not revision: 

84 raise SystemExit(f"error: {flag} needs a revision") 

85 if revision.startswith("-"): 

86 raise SystemExit( 

87 f"error: {flag} revision {revision!r} may not start with '-' " 

88 "(git would read it as an option)" 

89 ) 

90 return revision 

91 

92 

93def _git_diff(argv: list[str], label: str) -> str: 

94 """Run a read-only git command and return its stdout, or exit with its error.""" 

95 import subprocess # local: keeps the module importable where git is absent 

96 

97 try: 

98 proc = subprocess.run(argv, capture_output=True, text=True, timeout=120) 

99 except (OSError, subprocess.SubprocessError) as exc: 

100 raise SystemExit(f"error: could not run git for {label}: {redact(str(exc))[0]}") from None 

101 if proc.returncode != 0: 

102 detail = (proc.stderr or "").strip().splitlines() 

103 raise SystemExit( 

104 f"error: git could not resolve {label}" 

105 + (f": {detail[0]}" if detail else "") 

106 ) 

107 if not proc.stdout.strip(): 

108 raise SystemExit(f"error: {label} produced an empty diff — nothing to review") 

109 # Same ingest ceiling every other source honours; _read_capped wants a handle. 

110 return _read_capped(io.StringIO(proc.stdout), label) 

111 

112 

113def _read_diff(args) -> tuple[str, str]: 

114 """Return (diff, context).""" 

115 if getattr(args, "commit", None): 

116 rev = _checked_revision(args.commit, "--commit") 

117 # `git show` of a merge commit prints no diff by default; -m picks the 

118 # first-parent view so a merge is reviewable rather than silently empty. 

119 return _git_diff( 

120 ["git", "show", "--format=", "--patch", "-m", "--first-parent", rev, "--"], 

121 f"commit {rev}", 

122 ), "" 

123 if getattr(args, "commits", None): 

124 rev = _checked_revision(args.commits, "--commits") 

125 return _git_diff(["git", "diff", rev, "--"], f"range {rev}"), "" 

126 if args.pr: 

127 return pr_diff(args.pr, args.repo), pr_context(args.pr, args.repo) 

128 if args.issue: 

129 # Issue mode (issue #221): the issue's rendered text takes the diff slot; 

130 # there is no separate context block (title/labels are folded into it). 

131 return issue_body(args.issue, args.repo), "" 

132 if args.diff_file: 

133 if args.diff_file == "-": 

134 # Prefer the byte stream so the cap is exact; fall back to the text 

135 # stream (e.g. a StringIO test double) which lacks ``.buffer``. 

136 return _read_capped(getattr(sys.stdin, "buffer", sys.stdin), "stdin"), "" 

137 with Path(args.diff_file).open("rb") as fh: 

138 return _read_capped(fh, args.diff_file), "" 

139 raise SystemExit( 

140 "error: provide one of --pr, --issue, --diff-file, --commit, --commits " 

141 "(or --diff-file - for stdin)" 

142 ) 

143 

144 

145def build_parser() -> argparse.ArgumentParser: 

146 p = argparse.ArgumentParser( 

147 prog="jury", 

148 description="Cross-vendor multi-agent PR review jury.", 

149 ) 

150 src = p.add_argument_group("input") 

151 src.add_argument("--pr", help="GitHub PR number/URL to review (uses `gh`)") 

152 src.add_argument( 

153 "--issue", 

154 help="GitHub issue number/URL to review for completeness/clarity (uses " 

155 "`gh`); runs the full jury with an issue-quality rubric", 

156 ) 

157 src.add_argument("--repo", help="owner/name for --pr/--issue (defaults to current repo)") 

158 src.add_argument("--diff-file", help="path to a diff file, or '-' for stdin") 

159 src.add_argument("--commit", help="review the diff one commit introduces (needs a git repo)") 

160 src.add_argument( 

161 "--commits", 

162 help="review a commit range, e.g. origin/main..HEAD or HEAD~5..HEAD " 

163 "(needs a git repo)", 

164 ) 

165 

166 p.add_argument("--config", help="path to jury.toml (default: ./jury.toml or built-in)") 

167 p.add_argument( 

168 "--policy", 

169 type=Path, 

170 default=None, 

171 help="path to an optional repository review policy file (default: " 

172 "auto-discover .jury/policy.toml or jury-policy.toml); " 

173 "missing policy files are allowed", 

174 ) 

175 p.add_argument( 

176 "--context-mode", 

177 choices=["diff-only", "expanded"], 

178 default=None, 

179 help="context policy: diff-only sends only the diff; expanded includes PR context", 

180 ) 

181 p.add_argument( 

182 "--redact", 

183 dest="redact", 

184 action="store_true", 

185 default=None, 

186 help="redact secrets from prompt text before sending (default: from config)", 

187 ) 

188 p.add_argument( 

189 "--no-redact", 

190 dest="redact", 

191 action="store_false", 

192 help="do not redact secrets before sending", 

193 ) 

194 p.add_argument( 

195 "--rounds", 

196 type=int, 

197 help="override number of rounds (1=review, 2=+debate); a fixed value " 

198 "disables early-stop for reproducible benchmarking", 

199 ) 

200 p.add_argument( 

201 "--max-rounds", 

202 type=int, 

203 help="ceiling on adaptive rounds when early-stop is on", 

204 ) 

205 p.add_argument( 

206 "--early-stop", 

207 dest="early_stop", 

208 action="store_true", 

209 default=None, 

210 help="stop after round 1 when reviewers agree; debate only on disagreement", 

211 ) 

212 p.add_argument( 

213 "--no-early-stop", 

214 dest="early_stop", 

215 action="store_false", 

216 help="disable adaptive early-stop (honour a fixed number of rounds)", 

217 ) 

218 p.add_argument( 

219 "--auto", 

220 dest="auto", 

221 action="store_true", 

222 default=None, 

223 help="risk-aware auto-depth: scale rounds/verify to the diff", 

224 ) 

225 p.add_argument( 

226 "--no-auto", 

227 dest="auto", 

228 action="store_false", 

229 help="disable auto-depth (use configured/fixed rounds)", 

230 ) 

231 p.add_argument( 

232 "--total-timeout", 

233 type=int, 

234 help="overall wall-clock budget (seconds) for the whole run", 

235 ) 

236 p.add_argument( 

237 "--phase-timeout", 

238 type=int, 

239 help="per-phase wall-clock budget (seconds)", 

240 ) 

241 p.add_argument( 

242 "--retries", 

243 type=int, 

244 help="extra attempts for transient (timeout/rate-limit/spawn) failures", 

245 ) 

246 p.add_argument( 

247 "--max-diff-bytes", 

248 type=int, 

249 help="size budget for the (filtered) diff before chunking/too-large", 

250 ) 

251 p.add_argument( 

252 "--chunk", 

253 dest="chunk", 

254 action="store_true", 

255 default=None, 

256 help="chunk an over-budget diff by file instead of failing", 

257 ) 

258 p.add_argument( 

259 "--no-chunk", 

260 dest="chunk", 

261 action="store_false", 

262 help="disable diff chunking (fail clearly when over budget)", 

263 ) 

264 p.add_argument( 

265 "--exclude", 

266 action="append", 

267 metavar="GLOB", 

268 default=None, 

269 help="exclude files matching this path glob (repeatable)", 

270 ) 

271 p.add_argument( 

272 "--include", 

273 action="append", 

274 metavar="GLOB", 

275 default=None, 

276 help="only review files matching this path glob (repeatable)", 

277 ) 

278 p.add_argument( 

279 "--seed", 

280 type=int, 

281 help="run seed for reproducible orchestration; mock runs with the same seed " 

282 "produce byte-identical reports (overrides [jury] seed)", 

283 ) 

284 p.add_argument("--chair", help="override the synthesizing chair agent") 

285 p.add_argument( 

286 "--mock", action="store_true", help="offline demo: use deterministic mock agents" 

287 ) 

288 p.add_argument( 

289 "--strict", action="store_true", help="fail if any configured agent CLI is missing" 

290 ) 

291 p.add_argument( 

292 "--verify", 

293 dest="verify", 

294 action="store_true", 

295 default=None, 

296 help="run the verification round (default: from config)", 

297 ) 

298 p.add_argument( 

299 "--no-verify", 

300 dest="verify", 

301 action="store_false", 

302 help="skip the verification round", 

303 ) 

304 p.add_argument( 

305 "--doctor", 

306 action="store_true", 

307 help="print a local readiness diagnostics report and exit (no telemetry is collected or sent)", 

308 ) 

309 p.add_argument( 

310 "--write", 

311 help="with --doctor, also write the diagnostics as JSON to this path (secrets redacted)", 

312 ) 

313 p.add_argument("-o", "--output", help="write the report to a file instead of stdout") 

314 p.add_argument( 

315 "--metadata-json", 

316 metavar="PATH", 

317 help="write machine-readable run metadata (durations, status, rounds) as JSON", 

318 ) 

319 p.add_argument( 

320 "--format", 

321 choices=["markdown", "json", "sarif"], 

322 default="markdown", 

323 help="output format for stdout/--output (default: markdown)", 

324 ) 

325 p.add_argument( 

326 "--decision", 

327 choices=["chair", "vote"], 

328 default=None, 

329 help="final verdict: 'chair' synthesis (default) or panel 'vote' (tally " 

330 "the reviewers); overrides [jury] decision", 

331 ) 

332 p.add_argument( 

333 "--transcript", 

334 dest="transcript", 

335 action="store_true", 

336 default=None, 

337 help="render the full play-by-play transcript (each agent's review, the " 

338 "debate, and the chair's reasoning) instead of the summary report", 

339 ) 

340 p.add_argument( 

341 "--no-transcript", 

342 dest="transcript", 

343 action="store_false", 

344 help="force the summary report even if [jury] transcript is set", 

345 ) 

346 p.add_argument( 

347 "--verbose", 

348 dest="verbose", 

349 action="store_true", 

350 help="summary report followed by the full transcript, in one document", 

351 ) 

352 p.add_argument( 

353 "--live", 

354 dest="live", 

355 action="store_true", 

356 help="stream each step (review, debate, verdict) to stdout as it happens; " 

357 "add --pr --post to also post each step as its own PR comment", 

358 ) 

359 p.add_argument( 

360 "--theater", 

361 dest="theater", 

362 action="store_true", 

363 default=None, 

364 help="animated deliberation view of the live run (each model seated " 

365 "around a table, speaking per phase, panel-vote/chair finale); needs an " 

366 "interactive terminal, else falls back to --live. Can be defaulted on in " 

367 "jury.toml ([jury] theater = true)", 

368 ) 

369 p.add_argument( 

370 "--no-theater", 

371 dest="theater", 

372 action="store_false", 

373 help="disable the theater scene even if jury.toml enables it", 

374 ) 

375 p.add_argument( 

376 "--theater-style", 

377 dest="theater_style", 

378 choices=("flat", "pixel"), 

379 default=None, 

380 help="--theater scene style: 'flat' (ANSI line scene, default) or " 

381 "'pixel' (pixel-art room; needs a truecolor+unicode terminal). Defaults " 

382 "from jury.toml ([jury] theater_style)", 

383 ) 

384 p.add_argument( 

385 "--post-summary", 

386 "--post", 

387 dest="post_summary", 

388 action="store_true", 

389 help="post the report as a single summary comment on --pr", 

390 ) 

391 p.add_argument( 

392 "--post-inline", 

393 dest="post_inline", 

394 action="store_true", 

395 help="post inline review comments for located findings on --pr", 

396 ) 

397 p.add_argument( 

398 "--post-progress", 

399 dest="post_progress", 

400 action="store_true", 

401 help="keep a live, sticky status comment on --pr updated per round/chunk", 

402 ) 

403 p.add_argument( 

404 "--post-mode", 

405 choices=["single", "phased"], 

406 default="single", 

407 help="with --post-summary: 'single' (one comment) or 'phased' (separate " 

408 "Round 1 / debate / decision comments)", 

409 ) 

410 p.add_argument( 

411 "--dry-run", 

412 dest="dry_run", 

413 action="store_true", 

414 help="with --post-inline, print what would be posted without calling GitHub", 

415 ) 

416 p.add_argument( 

417 "--label", 

418 dest="label", 

419 action="store_true", 

420 help="apply classification labels (review effort / risk / security) to " 

421 "--pr (off by default; never applied automatically)", 

422 ) 

423 p.add_argument( 

424 "--ci", 

425 action="store_true", 

426 help="CI mode: exit non-zero when blocking findings remain", 

427 ) 

428 p.add_argument( 

429 "--fail-on", 

430 help="comma-separated severities that fail CI (overrides config)", 

431 ) 

432 p.add_argument( 

433 "--cache", 

434 action="store_true", 

435 help="use the local result cache: reuse a cached outcome for an unchanged " 

436 "diff+config, else run and store it (off by default)", 

437 ) 

438 p.add_argument( 

439 "--clear-cache", 

440 action="store_true", 

441 help="delete all local cache entries and exit (also: `jury cache clear`)", 

442 ) 

443 p.add_argument( 

444 "--cache-dir", 

445 help="override the cache directory (default: $JURY_CACHE_DIR or ~/.cache/ai-jury)", 

446 ) 

447 p.add_argument( 

448 "--suggest-patches", 

449 dest="suggest_patches", 

450 action="store_true", 

451 help="emit a separate, opt-in suggested-patches section for VERIFIED " 

452 "findings (read-only; never applied automatically)", 

453 ) 

454 p.add_argument( 

455 "--patches-out", 

456 metavar="PATH", 

457 help="with --suggest-patches, write the patches to this file instead of " 

458 "appending them after the report", 

459 ) 

460 p.add_argument( 

461 "--incremental", 

462 action="store_true", 

463 help="review only the diff since the last jury run on --pr when a prior " 

464 "marker exists, else fall back to a full review", 

465 ) 

466 p.add_argument("-q", "--quiet", action="store_true", help="suppress progress logs on stderr") 

467 p.add_argument( 

468 "--config-validate", 

469 action="store_true", 

470 help="validate the resolved config and exit (0 valid, 2 invalid)", 

471 ) 

472 p.add_argument( 

473 "--strict-config", 

474 action="store_true", 

475 help="treat configuration warnings as errors", 

476 ) 

477 p.add_argument( 

478 "--tiered", 

479 action="store_true", 

480 help="opt-in risk-aware tiered model routing with frontier anchor (issue #524)", 

481 ) 

482 p.add_argument( 

483 "--hints", 

484 action="store_true", 

485 help="run local static analysis pre-pass (Ruff/ESLint) to inject hints (issue #523)", 

486 ) 

487 p.add_argument("--version", action="version", version=f"%(prog)s {__version__}") 

488 return p 

489 

490 

491def _run_apply(rest: list[str]) -> int: 

492 """Handle `jury apply` (issue #521): apply verified patch suggestions.""" 

493 from .patches import apply_patch_suggestion, parse_patch_suggestions 

494 

495 sub = argparse.ArgumentParser( 

496 prog="jury apply", description="Apply verified suggested patches to the repository." 

497 ) 

498 sub.add_argument( 

499 "index", 

500 nargs="?", 

501 default="all", 

502 help="1-indexed patch suggestion number to apply, or 'all' (default: all)", 

503 ) 

504 sub.add_argument( 

505 "--report", 

506 "-r", 

507 help="Path to a markdown report file or patch file (defaults to stdin)", 

508 ) 

509 ns = sub.parse_args(rest) 

510 

511 content = "" 

512 if ns.report: 

513 p = Path(ns.report) 

514 if not p.exists(): 

515 print(f"Error: report file not found: {ns.report}", file=sys.stderr) 

516 return 2 

517 content = p.read_text(encoding="utf-8") 

518 elif sys.stdin is not None and not sys.stdin.isatty(): 

519 content = sys.stdin.read() 

520 else: 

521 print( 

522 "Error: provide a report file via --report <file> or pipe a report via stdin", 

523 file=sys.stderr, 

524 ) 

525 return 2 

526 

527 suggestions = parse_patch_suggestions(content) 

528 if not suggestions: 

529 print("No verified patch suggestions found in the provided report.", file=sys.stderr) 

530 return 1 

531 

532 if ns.index.lower() == "all": 

533 success_count = 0 

534 for i, s in enumerate(suggestions, 1): 

535 ok, msg = apply_patch_suggestion(s) 

536 if ok: 

537 success_count += 1 

538 print(f"✓ [{i}/{len(suggestions)}] {msg}") 

539 else: 

540 print(f"✗ [{i}/{len(suggestions)}] {msg}", file=sys.stderr) 

541 return 0 if success_count > 0 else 1 

542 

543 try: 

544 target_idx = int(ns.index) - 1 

545 if not (0 <= target_idx < len(suggestions)): 

546 print( 

547 f"Error: patch index {ns.index} out of range (found {len(suggestions)} suggestions)", 

548 file=sys.stderr, 

549 ) 

550 return 2 

551 s = suggestions[target_idx] 

552 ok, msg = apply_patch_suggestion(s) 

553 if ok: 

554 print(f"{msg}") 

555 return 0 

556 print(f"{msg}", file=sys.stderr) 

557 return 1 

558 except ValueError: 

559 print(f"Error: invalid index '{ns.index}'", file=sys.stderr) 

560 return 2 

561 

562 

563def _run_comment_command(rest: list[str]) -> int: 

564 """Handle ``jury comment`` (issue #11): parse an allowlisted PR-comment 

565 command and either print the resolved jury args or dispatch the run. 

566 

567 Returns 2 on a rejected/invalid command (so a workflow can ignore it), else 

568 the dispatched run's exit code (or 0 with --print-args). 

569 """ 

570 import shlex 

571 

572 from .commands import CommandError, parse_comment 

573 

574 sub = argparse.ArgumentParser(prog="jury comment", add_help=True) 

575 sub.add_argument("--text", required=True, help="the PR comment body to parse") 

576 sub.add_argument("--pr", help="PR number/URL to review and post back to") 

577 sub.add_argument("--repo", help="owner/name (defaults to current repo)") 

578 sub.add_argument( 

579 "--print-args", 

580 dest="print_args", 

581 action="store_true", 

582 help="print the resolved jury args instead of running", 

583 ) 

584 sub.add_argument( 

585 "--no-post", 

586 dest="no_post", 

587 action="store_true", 

588 help="do not post the result back as a summary comment", 

589 ) 

590 ns = sub.parse_args(rest) 

591 

592 try: 

593 parsed = parse_comment(ns.text) 

594 except CommandError as exc: 

595 print(f"comment command rejected: {redact(str(exc))[0]}", file=sys.stderr) 

596 return 2 

597 

598 inner = parsed.to_cli_args() 

599 if ns.pr: 

600 inner += ["--pr", ns.pr] 

601 if not ns.no_post: 

602 inner += ["--post-summary"] 

603 if ns.repo: 

604 inner += ["--repo", ns.repo] 

605 

606 if ns.print_args: 

607 print(" ".join(shlex.quote(a) for a in inner)) 

608 return 0 

609 return main(inner) 

610 

611 

612_AGENT_BLURB = { 

613 "claude": "Claude Code (Anthropic)", 

614 "codex": "Codex CLI (OpenAI)", 

615 "agy": "Antigravity (Google)", 

616 "qwen": "local / open-weight via Ollama (free, offline)", 

617 "claude-api": "hosted Anthropic API (ANTHROPIC_API_KEY, no CLI needed)", 

618 "codex-api": "hosted OpenAI API (OPENAI_API_KEY, no CLI needed)", 

619 "gemini-api": "hosted Google Gemini API (GEMINI_API_KEY, no CLI needed)", 

620 "openrouter": "hosted OpenRouter API (OPENROUTER_API_KEY)", 

621 "deepseek": "hosted DeepSeek API (DEEPSEEK_API_KEY)", 

622 "groq": "hosted Groq API (GROQ_API_KEY)", 

623 "aider": "generic CLI coding agent (Aider)", 

624} 

625 

626 

627def _init_available() -> dict: 

628 """Map each known agent name to whether it is reachable right now.""" 

629 from .adapters import make_adapter 

630 from .config import AgentSpec 

631 from .scaffold import KNOWN_AGENTS, agent_templates 

632 

633 templates = agent_templates() 

634 out = {} 

635 for name in KNOWN_AGENTS: 

636 try: 

637 out[name] = make_adapter(AgentSpec(**templates[name])).available() 

638 except Exception: # noqa: BLE001 - detection is best-effort 

639 out[name] = False 

640 return out 

641 

642 

643def _init_interactive(available: dict, input_fn=input, local_endpoint=None, models_fn=None) -> dict: 

644 """Prompt for jury settings; returns kwargs for scaffold.build_config. 

645 

646 ``input_fn`` and ``models_fn`` are injectable for testing (the latter lists 

647 local models). Defaults are pre-filled from the detected agents/models so 

648 pressing Enter accepts a sensible config. 

649 """ 

650 from .scaffold import KNOWN_AGENTS 

651 

652 if models_fn is None: 

653 from .adapters import list_local_models as models_fn 

654 

655 print("Configure a review jury (jury.toml).\n", file=sys.stderr) 

656 for name in KNOWN_AGENTS: 

657 mark = "available" if available.get(name) else "not found" 

658 print(f" - {name}: {_AGENT_BLURB[name]} [{mark}]", file=sys.stderr) 

659 default_agents = [n for n in KNOWN_AGENTS if available.get(n)] or list(KNOWN_AGENTS) 

660 raw_agents = input_fn(f"\nAgents to include [default: {','.join(default_agents)}]: ").strip() 

661 agents = [a.strip() for a in raw_agents.split(",") if a.strip()] or default_agents 

662 

663 rounds_raw = input_fn("Rounds — 1=review, 2=+debate [2]: ").strip() 

664 rounds = int(rounds_raw) if rounds_raw.isdigit() else 2 

665 

666 chair_default = agents[0] if agents else "claude" 

667 chair = input_fn(f"Chair agent [{chair_default}]: ").strip() or chair_default 

668 

669 verify = (input_fn("Run verification round? [Y/n]: ").strip().lower() or "y") != "n" 

670 

671 local_model = None 

672 has_local = any(a in agents for a in ("qwen", "local")) 

673 if has_local: 

674 from .scaffold import pick_default_model 

675 

676 models = models_fn(local_endpoint or "http://localhost:11434/v1") 

677 if models: 

678 default = pick_default_model(models) 

679 print("\nLocal models available on the server:", file=sys.stderr) 

680 for i, m in enumerate(models, 1): 

681 star = " (default)" if m == default else "" 

682 print(f" {i}. {m}{star}", file=sys.stderr) 

683 raw = input_fn(f"Pick a local model [number or name, default: {default}]: ").strip() 

684 if raw.isdigit() and 1 <= int(raw) <= len(models): 

685 local_model = models[int(raw) - 1] 

686 elif raw: 

687 local_model = raw 

688 else: 

689 local_model = default 

690 else: 

691 print( 

692 "\n(could not reach the local server to list models; using the default)", 

693 file=sys.stderr, 

694 ) 

695 local_model = input_fn("Local model name [qwen2.5-coder:7b]: ").strip() or None 

696 

697 return { 

698 "agents": agents, 

699 "rounds": rounds, 

700 "chair": chair, 

701 "verify": verify, 

702 "local_model": local_model, 

703 } 

704 

705 

706def _init_wizard(available: dict, input_fn=input, local_endpoint=None, models_fn=None) -> dict: 

707 """Guided, numbered-option setup for ``jury init --wizard`` (issue #231). 

708 

709 Mirrors :func:`_init_interactive`'s injectable params for offline testing. 

710 Every question is SKIPPABLE: pressing Enter leaves the setting unset, so it 

711 falls back to the built-in default and is NOT written to ``jury.toml`` (which 

712 keeps the generated file minimal). Returns kwargs for ``scaffold.build_config`` 

713 containing only the values the user explicitly chose. 

714 """ 

715 from .scaffold import KNOWN_AGENTS 

716 

717 if models_fn is None: 

718 from .adapters import list_local_models as models_fn 

719 

720 def ask(prompt: str) -> str: 

721 return input_fn(prompt).strip() 

722 

723 def choose(prompt: str, options: list[str], default_idx: int) -> int | None: 

724 """Print numbered options and read a 1-based pick. Enter -> None (skip).""" 

725 print(prompt, file=sys.stderr) 

726 for i, label in enumerate(options, 1): 

727 star = " (default)" if i - 1 == default_idx else "" 

728 print(f" {i}. {label}{star}", file=sys.stderr) 

729 raw = ask("Pick a number [Enter to keep default]: ") 

730 if not raw: 

731 return None 

732 if raw.isdigit() and 1 <= int(raw) <= len(options): 

733 return int(raw) - 1 

734 return None 

735 

736 print( 

737 "jury init --wizard — guided setup (writes jury.toml).\n" 

738 "Every question is optional: press Enter to keep the default and skip it;\n" 

739 "skipped settings are left at their built-in defaults (not written).\n", 

740 file=sys.stderr, 

741 ) 

742 

743 # Reviewers (always written — like plain init). 

744 for name in KNOWN_AGENTS: 

745 mark = "available" if available.get(name) else "not found" 

746 print(f" - {name}: {_AGENT_BLURB[name]} [{mark}]", file=sys.stderr) 

747 default_agents = [n for n in KNOWN_AGENTS if available.get(n)] or list(KNOWN_AGENTS) 

748 raw_agents = ask(f"\nReviewers to include [default: {','.join(default_agents)}]: ") 

749 agents = [a.strip() for a in raw_agents.split(",") if a.strip()] or default_agents 

750 

751 kwargs: dict = {"agents": agents} 

752 

753 # Depth -> rounds / early_stop / auto_depth. 

754 depth = choose( 

755 "\nDepth:", 

756 [ 

757 "1 round (review only)", 

758 "2 rounds + debate", 

759 "adaptive (early-stop)", 

760 "auto-depth (scale to the diff)", 

761 ], 

762 default_idx=1, 

763 ) 

764 if depth == 0: 

765 kwargs["rounds"] = 1 

766 elif depth == 1: 

767 kwargs["rounds"] = 2 

768 elif depth == 2: 

769 kwargs["rounds"] = 2 

770 kwargs["early_stop"] = True 

771 elif depth == 3: 

772 kwargs["auto_depth"] = True 

773 

774 # Decision: chair (default) or panel vote. Only written on a non-default. 

775 decision = choose("\nDecision:", ["chair synthesis", "panel vote"], default_idx=0) 

776 if decision == 1: 

777 kwargs["decision"] = "vote" 

778 

779 # Verification (always written — like plain init). 

780 verify_raw = ask("\nRun verification round? [Y/n]: ").lower() 

781 if verify_raw: 

782 kwargs["verify"] = verify_raw != "n" 

783 

784 # Context: diff-only (default) or expanded; redact secrets Y/n. 

785 ctx = choose( 

786 "\nContext sent to reviewers:", 

787 ["diff-only", "expanded (include PR context)"], 

788 default_idx=0, 

789 ) 

790 if ctx == 1: 

791 kwargs["context_mode"] = "expanded" 

792 redact_raw = ask("Redact secrets before sending? [Y/n]: ").lower() 

793 if redact_raw == "n": 

794 kwargs["redact_secrets"] = False 

795 

796 # CI gate fail-on. Only write [jury.ci] on a non-default pick. 

797 gate = choose( 

798 "\nCI gate — fail on which severities?", 

799 ["critical,major", "critical only", "skip (never fail CI)"], 

800 default_idx=0, 

801 ) 

802 if gate == 1: 

803 kwargs["ci_fail_on"] = ["critical"] 

804 elif gate == 2: 

805 kwargs["ci_fail_on"] = [] 

806 

807 # Chair (always written — like plain init; default = first reviewer). 

808 chair_default = agents[0] if agents else "claude" 

809 chair = ask(f"\nChair agent [{chair_default}]: ") or chair_default 

810 kwargs["chair"] = chair 

811 

812 # Local model pick when a local reviewer is chosen (reuse init's logic). 

813 if any(a in agents for a in ("qwen", "local")): 

814 from .scaffold import pick_default_model 

815 

816 models = models_fn(local_endpoint or "http://localhost:11434/v1") 

817 if models: 

818 default = pick_default_model(models) 

819 print("\nLocal models available on the server:", file=sys.stderr) 

820 for i, m in enumerate(models, 1): 

821 star = " (default)" if m == default else "" 

822 print(f" {i}. {m}{star}", file=sys.stderr) 

823 raw = ask(f"Pick a local model [number or name, default: {default}]: ") 

824 if raw.isdigit() and 1 <= int(raw) <= len(models): 

825 kwargs["local_model"] = models[int(raw) - 1] 

826 elif raw: 

827 kwargs["local_model"] = raw 

828 else: 

829 kwargs["local_model"] = default 

830 else: 

831 print( 

832 "\n(could not reach the local server to list models; using the default)", 

833 file=sys.stderr, 

834 ) 

835 typed = ask("Local model name [qwen2.5-coder:7b]: ") 

836 if typed: 

837 kwargs["local_model"] = typed 

838 

839 return kwargs 

840 

841 

842def _run_init(rest: list[str]) -> int: 

843 """Handle ``jury init`` (issue #107): scaffold a jury.toml.""" 

844 from .config import ConfigError, validate_config 

845 from .scaffold import KNOWN_AGENTS, PRESETS, build_config, render_toml 

846 

847 sub = argparse.ArgumentParser(prog="jury init") 

848 sub.add_argument( 

849 "--preset", 

850 choices=sorted(PRESETS), 

851 help="setup preset: offline (local-only), fast (1 round), balanced " 

852 "(debate + early-stop), thorough (all agents + debate + verify)", 

853 ) 

854 sub.add_argument("--agents", help="comma-separated: claude,codex,agy,qwen") 

855 sub.add_argument("--rounds", type=int, default=None) 

856 sub.add_argument("--chair") 

857 sub.add_argument("--verify", dest="verify", action="store_true", default=None) 

858 sub.add_argument("--no-verify", dest="verify", action="store_false") 

859 sub.add_argument("--local-model", help="model id for a local agent (qwen)") 

860 sub.add_argument("--local-endpoint", help="OpenAI-compatible base URL for a local agent") 

861 sub.add_argument("-o", "--output", default="jury.toml") 

862 sub.add_argument("--force", action="store_true", help="overwrite an existing file") 

863 sub.add_argument("--interactive", action="store_true", help="force interactive prompts") 

864 sub.add_argument( 

865 "--wizard", 

866 action="store_true", 

867 help="guided, numbered-option setup; every question is skippable (Enter " 

868 "keeps the built-in default) and only chosen keys are written", 

869 ) 

870 sub.add_argument( 

871 "--list-agents", action="store_true", help="list known agents + availability and exit" 

872 ) 

873 sub.add_argument( 

874 "--list-models", action="store_true", help="list local models on the server and exit" 

875 ) 

876 ns = sub.parse_args(rest) 

877 

878 from .adapters import list_local_models 

879 from .redaction import redact_url_userinfo 

880 

881 endpoint = ns.local_endpoint or "http://localhost:11434/v1" 

882 # Strip any userinfo credentials before echoing the endpoint to stdout/CI 

883 # logs (issue #316/L-7, completed in v1.5.0/L-1: structural strip catches 

884 # short and colon-less userinfo the regex missed), mirroring doctor.py. 

885 endpoint_disp = redact_url_userinfo(endpoint) 

886 

887 if ns.list_models: 

888 models = list_local_models(endpoint) 

889 if not models: 

890 print(f"No local models found (is a server reachable at {endpoint_disp}?).") 

891 return 0 

892 print(f"Local models at {endpoint_disp}:") 

893 for m in models: 

894 print(f" - {m}") 

895 return 0 

896 

897 available = _init_available() 

898 

899 if ns.list_agents: 

900 for name in KNOWN_AGENTS: 

901 mark = "available" if available.get(name) else "not found" 

902 print(f"{name:8} {_AGENT_BLURB[name]:45} [{mark}]") 

903 # Show discovered local models so the user sees what they can pick. 

904 models = list_local_models(endpoint) 

905 if models: 

906 print(f"\nlocal models at {endpoint_disp}: {', '.join(models)}") 

907 return 0 

908 

909 preset = PRESETS.get(ns.preset, {}) 

910 

911 def _detected_agents(): 

912 return [n for n in KNOWN_AGENTS if available.get(n)] 

913 

914 def _resolve_preset_agents(spec): 

915 if spec == "all": 

916 return list(KNOWN_AGENTS) 

917 if spec == "detected": 

918 return _detected_agents() or list(KNOWN_AGENTS) 

919 return list(spec) 

920 

921 # rounds / verify / early_stop: explicit flag > preset > built-in default. 

922 rounds = ns.rounds if ns.rounds is not None else preset.get("rounds", 2) 

923 verify = ns.verify if ns.verify is not None else preset.get("verify", True) 

924 early_stop = preset.get("early_stop") 

925 

926 # Guided wizard (issue #231): opt-in via --wizard. A numbered-option flow 

927 # where every question is skippable; only explicitly-chosen settings are 

928 # written, so the file stays minimal. Runs regardless of TTY (it is explicit). 

929 if ns.wizard: 

930 kwargs = _init_wizard(available, local_endpoint=ns.local_endpoint) 

931 kwargs["local_endpoint"] = ns.local_endpoint 

932 if ns.local_model: 

933 kwargs["local_model"] = ns.local_model 

934 # Interactive only when neither --agents nor --preset was given and we're on a 

935 # TTY (or --interactive). Presets/flags are non-interactive by design. 

936 elif not ns.agents and not ns.preset and (ns.interactive or sys.stdin.isatty()): 

937 kwargs = _init_interactive(available, local_endpoint=ns.local_endpoint) 

938 kwargs["local_endpoint"] = ns.local_endpoint 

939 if ns.local_model: 

940 kwargs["local_model"] = ns.local_model 

941 else: 

942 if ns.agents: 

943 agents = [a.strip() for a in ns.agents.split(",") if a.strip()] 

944 elif ns.preset: 

945 agents = _resolve_preset_agents(preset["agents"]) 

946 else: 

947 agents = _detected_agents() 

948 if not agents: 

949 print( 

950 "error: no agents detected and none specified; pass --agents " 

951 "or --preset (e.g. --preset offline), or run interactively.", 

952 file=sys.stderr, 

953 ) 

954 return 2 

955 kwargs = { 

956 "agents": agents, 

957 "rounds": rounds, 

958 "chair": ns.chair, 

959 "verify": verify, 

960 "early_stop": early_stop, 

961 "local_model": ns.local_model, 

962 "local_endpoint": ns.local_endpoint, 

963 } 

964 

965 try: 

966 config = build_config(**kwargs) 

967 except ValueError as exc: 

968 print(f"error: {redact(str(exc))[0]}", file=sys.stderr) 

969 return 2 

970 

971 # The scaffolded config must itself be valid (fail loudly if a template drifts). 

972 try: 

973 validate_config(config) 

974 except ConfigError as exc: 

975 print(f"error: generated config is invalid: {redact(str(exc))[0]}", file=sys.stderr) 

976 return 2 

977 

978 out_path = Path(ns.output) 

979 if out_path.exists() and not ns.force: 

980 print( 

981 f"error: {out_path} already exists; pass --force to overwrite.", 

982 file=sys.stderr, 

983 ) 

984 return 2 

985 

986 out_path.write_text(render_toml(config), encoding="utf-8") 

987 chosen = ", ".join(a["name"] for a in config["agent"]) 

988 print(f"Wrote {out_path} — panel: {chosen} · rounds: {config['jury']['rounds']}") 

989 print(f"Next: jury --config-validate --config {out_path}") 

990 print("Then: git diff main... | jury --diff-file -") 

991 return 0 

992 

993 

994def _config_source(config_arg) -> str: 

995 """Human-readable source of the config the jury would load.""" 

996 if config_arg: 

997 return str(config_arg) 

998 return "jury.toml" if Path("jury.toml").exists() else "(built-in defaults)" 

999 

1000 

1001def _render_effective_config(cfg) -> str: 

1002 """Render the EFFECTIVE resolved config as a readable summary (config show).""" 

1003 on = lambda b: "on" if b else "off" # noqa: E731 

1004 lines = [] 

1005 lines.append( 

1006 f"[jury] rounds={cfg.rounds} chair={cfg.chair} verify={on(cfg.verify)} " 

1007 f"parallel={on(cfg.parallel)} timeout={cfg.timeout}s" 

1008 ) 

1009 adaptive = f"early_stop={on(cfg.early_stop)} max_rounds={cfg.effective_max_rounds}" 

1010 budget = ( 

1011 f"total_timeout={cfg.total_timeout or '—'} " 

1012 f"phase_timeout={cfg.phase_timeout or '—'} retries={cfg.retries}" 

1013 ) 

1014 lines.append( 

1015 f" {adaptive} · {budget} · seed={cfg.seed if cfg.seed is not None else '—'}" 

1016 ) 

1017 lines.append( 

1018 f"[jury.ci] fail_on={cfg.ci.fail_on} ignore_unverified={on(cfg.ci.ignore_unverified)}" 

1019 ) 

1020 lines.append( 

1021 f"[jury.context] mode={cfg.context.mode} redact_secrets={on(cfg.context.redact_secrets)}" 

1022 ) 

1023 d = cfg.diff 

1024 lines.append( 

1025 f"[jury.diff] max_bytes={d.max_bytes} chunk={on(d.chunk)} " 

1026 f"exclude_generated={on(d.exclude_generated)} " 

1027 f"exclude={d.exclude or '[]'} include={d.include or '[]'}" 

1028 ) 

1029 lines.append("agents:") 

1030 for a in cfg.agents: 

1031 flag = "" if a.enabled else " (disabled)" 

1032 target = a.endpoint if a.vendor == "local" else (a.command or "—") 

1033 model = f" model={a.model}" if a.model else "" 

1034 lines.append(f" - {a.name} ({a.vendor}) → {target}{model}{flag}") 

1035 return "\n".join(lines) 

1036 

1037 

1038def _run_config(rest: list[str]) -> int: 

1039 """Handle ``jury config show|path``.""" 

1040 from .config import ConfigError, load_config 

1041 

1042 sub = argparse.ArgumentParser(prog="jury config") 

1043 sub.add_argument("action", choices=["show", "path"]) 

1044 sub.add_argument("--config", help="path to jury.toml (default: ./jury.toml or built-in)") 

1045 ns = sub.parse_args(rest) 

1046 

1047 source = _config_source(ns.config) 

1048 if ns.action == "path": 

1049 print(source) 

1050 return 0 

1051 

1052 try: 

1053 cfg = load_config(ns.config, validate=True) 

1054 except (ConfigError, FileNotFoundError) as exc: 

1055 print(f"error: {redact(str(exc))[0]}", file=sys.stderr) 

1056 return 2 

1057 print(f"source: {source}") 

1058 print(_render_effective_config(cfg)) 

1059 return 0 

1060 

1061 

1062def _run_replay(rest: list[str]) -> int: 

1063 """Handle ``jury replay <outcome.json>`` (issue #449). 

1064 

1065 Replays a saved run in the deliberation theater — or, off a TTY / without 

1066 ``--theater``, as the same plain step stream ``--live`` prints. Pure 

1067 presentation: no orchestration, no network, no agents. 

1068 """ 

1069 from .replay import ReplayError, load_outcome, replay_events, replay_into 

1070 

1071 sub = argparse.ArgumentParser( 

1072 prog="jury replay", 

1073 description="Replay a saved jury outcome (a result-cache entry or a " 

1074 "serialized outcome dict) in the deliberation theater. No agents run.", 

1075 ) 

1076 sub.add_argument( 

1077 "outcome", 

1078 help="path to a saved outcome JSON (cache entry or outcome dict)", 

1079 ) 

1080 sub.add_argument( 

1081 "--theater", 

1082 action="store_true", 

1083 help="replay in the animated deliberation scene (needs a wide TTY; " 

1084 "falls back to plain transcript lines otherwise)", 

1085 ) 

1086 sub.add_argument( 

1087 "--theater-style", 

1088 choices=["flat", "pixel"], 

1089 default="flat", 

1090 help="--theater scene style: 'flat' (ANSI line scene, default) or " 

1091 "'pixel' (half-block pixel-art room)", 

1092 ) 

1093 sub.add_argument( 

1094 "--decision", 

1095 choices=["chair", "vote"], 

1096 default="chair", 

1097 help="finale mode: 'chair' shows the stored synthesis verdict (default); " 

1098 "'vote' re-tallies the panel ballots for the vote finale", 

1099 ) 

1100 sub.add_argument( 

1101 "--mode", 

1102 choices=["code", "issue"], 

1103 default="code", 

1104 help="vote vocabulary for --decision vote (the serialized outcome does " 

1105 "not record the run mode): 'code' (APPROVE/COMMENT/REQUEST CHANGES, " 

1106 "default) or 'issue' (READY/UNCLEAR/NEEDS-INFO)", 

1107 ) 

1108 ns = sub.parse_args(rest) 

1109 

1110 try: 

1111 outcome = load_outcome(Path(ns.outcome)) 

1112 except ReplayError as exc: 

1113 print(f"error: {redact(str(exc))[0]}", file=sys.stderr) 

1114 return 2 

1115 

1116 # Panel-vote finale (mirrors the live path): re-tally from the stored 

1117 # groups/reviews — deterministic, no agents involved. 

1118 vote = None 

1119 if ns.decision == "vote": 

1120 from .voting import is_abstention, tally_votes 

1121 

1122 voters = [ 

1123 r.agent for r in outcome.reviews if r.ok and not is_abstention(getattr(r, "output", "")) 

1124 ] 

1125 vote = tally_votes(outcome.groups, voters, mode=ns.mode) 

1126 

1127 # Same TTY gate as the live path: the scene needs a wide TTY, otherwise 

1128 # degrade to the plain --live step stream. 

1129 court = None 

1130 if ns.theater: 

1131 from . import theater as _theater 

1132 

1133 if _theater.supports_scene(sys.stdout): 

1134 seats: dict[str, str] = {} 

1135 for r in outcome.reviews: 

1136 seats.setdefault(r.agent, r.vendor) 

1137 court = _theater.Courtroom( 

1138 list(seats.items()), 

1139 outcome.chair or "chair", 

1140 case=Path(ns.outcome).name, 

1141 decision=ns.decision, 

1142 style=ns.theater_style, 

1143 ) 

1144 

1145 if court is not None: 

1146 replay_into(court, outcome, vote=vote) 

1147 else: 

1148 for kind, result, round_no in replay_events(outcome): 

1149 title, body = render_live_step(kind, result, round_no) 

1150 print(f"## {title}\n\n{body}\n", flush=True) 

1151 if vote is not None: 

1152 # The vote finale must survive the transcript fallback too (review 

1153 # finding: --decision vote was computed then silently dropped here). 

1154 print("## Panel vote\n", flush=True) 

1155 for ballot in vote.ballots: 

1156 print(f"- {ballot.reviewer}: {ballot.vote} ({ballot.reason})", flush=True) 

1157 print(f"\nVerdict: {vote.verdict}\n", flush=True) 

1158 return 0 

1159 

1160 

1161_PROGRESS_PREFIXES = ( 

1162 "round ", 

1163 "reviewing chunk", 

1164 "verification", 

1165 "synthesis", 

1166 "diff size", 

1167 "early stop", 

1168 "auto-depth", 

1169) 

1170 

1171 

1172def _is_progress_milestone(msg: str) -> bool: 

1173 """Whether a log line is a coarse milestone worth a sticky-comment update.""" 

1174 return msg.startswith(_PROGRESS_PREFIXES) 

1175 

1176 

1177def _maybe_add_local_fallback(config, args, log) -> None: 

1178 """Append a local agent when nothing else can run, offline (issue: zero-config). 

1179 

1180 Only fires in the safe "fresh user" case: no explicit `--config`, no 

1181 `./jury.toml`, not `--mock`, none of the configured agents are available, 

1182 and a local OpenAI-compatible server is reachable with at least one model. 

1183 Mutates ``config`` in place and points the chair at the local agent. 

1184 """ 

1185 if args.config or args.mock or Path("jury.toml").exists(): 

1186 return 

1187 from .adapters import list_local_models, make_adapter 

1188 from .config import AgentSpec 

1189 from .scaffold import pick_default_model 

1190 

1191 try: 

1192 if any(make_adapter(s).available() for s in config.enabled_agents): 

1193 return 

1194 except Exception: # noqa: BLE001 - availability probing must never crash a run 

1195 return 

1196 models = list_local_models() 

1197 model = pick_default_model(models) 

1198 if not model: 

1199 return 

1200 config.agents.append( 

1201 AgentSpec(name="local", vendor="local", model=model, endpoint="http://localhost:11434/v1") 

1202 ) 

1203 config.chair = "local" 

1204 log(f"no agent CLIs found; using local model '{model}' (offline, $0)") 

1205 

1206 

1207def _force_utf8_output() -> None: 

1208 """Ensure stdout/stderr can emit the report's Unicode (emoji, arrows). 

1209 

1210 On Windows the console defaults to a legacy code page (e.g. cp1252) that 

1211 can't encode the report's `🏛️`/`⇄` characters, so `print(report)` raises 

1212 `UnicodeEncodeError`. Reconfigure the real streams to UTF-8 when possible; 

1213 `reconfigure` is absent on replaced streams (tests' StringIO, some pipes), 

1214 so this is a best-effort no-op there. 

1215 """ 

1216 for stream in (sys.stdout, sys.stderr): 

1217 reconfigure = getattr(stream, "reconfigure", None) 

1218 if reconfigure is not None: 

1219 with contextlib.suppress(ValueError, OSError): 

1220 reconfigure(encoding="utf-8") 

1221 

1222 

1223_OVERVIEW = """\ 

1224🏛️ ai-jury — a cross-vendor multi-agent review jury. 

1225 

1226It runs several coding-agent CLIs (Claude, Codex, Antigravity) plus an optional 

1227local model over the same diff, PR, or issue; they cross-examine and verify each 

1228other, and a chair (or a panel vote) synthesizes one verdict. 

1229 

1230Common commands: 

1231 jury init --wizard guided setup — writes a jury.toml (skippable) 

1232 jury --pr 123 review a pull request 

1233 jury --issue 42 review an issue for completeness 

1234 git diff | jury --diff-file - review the current branch's diff 

1235 jury examples more example commands 

1236 jury guide a short end-to-end walkthrough 

1237 jury --help every option 

1238 

1239Docs: https://github.com/berkayturanci/ai-jury""" 

1240 

1241_EXAMPLES = """\ 

1242ai-jury — example commands 

1243 

1244Setup 

1245 jury init --wizard guided setup (writes jury.toml) 

1246 jury init --preset thorough non-interactive preset 

1247 jury config show print the effective, resolved config 

1248 jury doctor check which agents/CLIs are available 

1249 

1250Review 

1251 jury --pr 123 review a pull request 

1252 jury --issue 42 review an issue for completeness 

1253 git diff | jury --diff-file - review the current branch's diff 

1254 jury --diff-file changes.patch review a saved patch 

1255 jury --pr 123 --verbose full play-by-play (rounds + transcript) 

1256 

1257Decide & gate 

1258 jury --pr 123 --decision vote verdict by panel vote (not a single chair) 

1259 jury --pr 123 --ci exit non-zero on a blocking finding (CI gate) 

1260 

1261Post results back to GitHub 

1262 jury --pr 123 --post-summary post one rollup comment 

1263 jury --pr 123 --post-inline post line-level review comments 

1264 jury --issue 42 --post-summary post the triage verdict on the issue 

1265 

1266Run `jury guide` for a walkthrough, or `jury --help` for every option.""" 

1267 

1268_GUIDE = """\ 

1269ai-jury — a short walkthrough 

1270 

12711. Install the agent CLIs you have (any subset works): Claude Code, Codex, 

1272 Antigravity. Optionally run a local model via Ollama for a free panelist. 

1273 Check what's available: 

1274 jury doctor 

1275 

12762. Create a config (picks reviewers, rounds, chair/vote, verify): 

1277 jury init --wizard 

1278 Every question is skippable — Enter keeps the built-in default. 

1279 

12803. Run your first review: 

1281 jury --pr 123 # a pull request 

1282 jury --issue 42 # an issue's completeness 

1283 git diff | jury --diff-file - # the current branch 

1284 

1285 The panel reviews independently, cross-examines (debate), the chair verifies 

1286 candidate findings to cut false positives, then synthesizes one verdict. 

1287 

12884. Post the verdict back to GitHub (optional): 

1289 jury --pr 123 --post-summary # one rollup comment 

1290 jury --pr 123 --post-inline # line-level comments 

1291 

12925. Gate CI on blocking findings (optional): 

1293 jury --pr 123 --ci # non-zero exit on critical/major 

1294 

1295Reviewers run sandboxed/read-only over attacker-controlled diffs by default. 

1296See `jury examples` for more, or `jury --help` for every option. 

1297Docs: https://github.com/berkayturanci/ai-jury""" 

1298 

1299 

1300def main(argv: list[str] | None = None) -> int: 

1301 _force_utf8_output() 

1302 raw = list(sys.argv[1:] if argv is None else argv) 

1303 

1304 # First-impression UX (#265): a newcomer running bare `jury` in a terminal 

1305 # gets a friendly overview and exits 0 — not the argparse error. The strict 

1306 # "provide one of --pr/--issue/--diff-file" error + non-zero exit is kept for 

1307 # non-interactive use (piped/CI), so scripts that forget an input still fail. 

1308 # `sys.stdin` can be None when stdin is detached (e.g. a background process), 

1309 # so guard before calling isatty(). 

1310 if not raw and sys.stdin is not None and sys.stdin.isatty(): 

1311 print(_OVERVIEW) 

1312 return 0 

1313 

1314 # Plain-language command overview / walkthrough (#265), argv-intercepts like 

1315 # the other subcommands so the main flag surface stays flat. Match exactly so 

1316 # trailing junk (`jury examples foo`) falls through to argparse and errors 

1317 # rather than being silently ignored. 

1318 if raw == ["examples"]: 

1319 print(_EXAMPLES) 

1320 return 0 

1321 if raw == ["guide"]: 

1322 print(_GUIDE) 

1323 return 0 

1324 # Documented `jury cache clear` UX (issue #33): handled before argparse so 

1325 # the rest of the CLI keeps its flat flag surface (no subcommands). 

1326 if raw[:2] == ["cache", "clear"]: 

1327 from .cache import Cache 

1328 

1329 # An optional --cache-dir may follow. 

1330 cache_dir = None 

1331 if "--cache-dir" in raw: 

1332 idx = raw.index("--cache-dir") 

1333 if idx + 1 < len(raw): 

1334 cache_dir = raw[idx + 1] 

1335 removed = Cache(cache_dir).clear() 

1336 print(f"Cleared {removed} cache entr{'y' if removed == 1 else 'ies'}.") 

1337 return 0 

1338 

1339 # Comment-command mode (issue #11): `jury comment --text "/jury review"` 

1340 # parses an allowlisted PR-comment command and dispatches a safe jury run. 

1341 # Handled before the main parser so the comment text is never confused with 

1342 # the jury's own flags, and never reaches a shell. 

1343 if raw[:1] == ["comment"]: 

1344 return _run_comment_command(raw[1:]) 

1345 

1346 # Config scaffolding (issue #107): `jury init` writes a jury.toml from 

1347 # detected agents / flags / interactive prompts. Intercepted before the main 

1348 # parser so it keeps its own small flag surface. 

1349 if raw[:1] == ["init"]: 

1350 return _run_init(raw[1:]) 

1351 

1352 # Config introspection: `jury config show` prints the EFFECTIVE resolved 

1353 # config + its source so you can see exactly what will run; `config path` 

1354 # prints just the source. 

1355 if raw[:1] == ["config"]: 

1356 return _run_config(raw[1:]) 

1357 

1358 # Apply verified suggested patches (issue #521): `jury apply` applies 

1359 # suggested patches directly to the working directory. 

1360 if raw[:1] == ["apply"]: 

1361 return _run_apply(raw[1:]) 

1362 

1363 # Theater replay (issue #449): `jury replay <outcome.json>` re-drives the 

1364 # deliberation scene from a saved outcome — no agents, no network. 

1365 # Intercepted before the main parser like the other subcommands. 

1366 if raw[:1] == ["replay"]: 

1367 return _run_replay(raw[1:]) 

1368 

1369 args = build_parser().parse_args(argv) 

1370 

1371 if args.clear_cache: 

1372 from .cache import Cache 

1373 

1374 removed = Cache(args.cache_dir).clear() 

1375 print(f"Cleared {removed} cache entr{'y' if removed == 1 else 'ies'}.") 

1376 return 0 

1377 

1378 if args.doctor: 

1379 diagnostics = doctor_module.build_diagnostics(args.config) 

1380 print(doctor_module.render_report(diagnostics)) 

1381 if args.write: 

1382 try: 

1383 Path(args.write).write_text( 

1384 json.dumps(diagnostics, indent=2) + "\n", encoding="utf-8" 

1385 ) 

1386 except OSError as exc: 

1387 print(f"error: {redact(str(exc))[0]}", file=sys.stderr) 

1388 return 2 

1389 print(f"\nWrote diagnostics to {args.write}") 

1390 return 0 

1391 

1392 if args.config_validate: 

1393 source = args.config or "jury.toml (or built-in defaults)" 

1394 try: 

1395 data = load_raw_config(args.config) 

1396 warnings = validate_config(data, strict=args.strict_config) 

1397 except (ConfigError, FileNotFoundError) as exc: 

1398 print(redact(f"Config invalid ({source}): {exc}")[0], file=sys.stderr) 

1399 return 2 

1400 if warnings: 

1401 print(f"Config valid with warnings ({source}):") 

1402 for w in warnings: 

1403 print(f" - {w}") 

1404 else: 

1405 print(f"Config valid ({source}).") 

1406 return 0 

1407 

1408 try: 

1409 config = load_config(args.config, validate=True, strict=args.strict_config) 

1410 except ConfigError as exc: 

1411 print(f"Config invalid: {redact(str(exc))[0]}", file=sys.stderr) 

1412 return 2 

1413 if args.rounds is not None: 

1414 config.rounds = args.rounds 

1415 # A fixed --rounds is a hard override: it disables adaptive early-stop so 

1416 # the run is reproducible fixed-N (issue #40), unless --early-stop is also 

1417 # passed explicitly (handled below). 

1418 config.early_stop = False 

1419 if args.max_rounds is not None: 

1420 config.max_rounds = args.max_rounds 

1421 if args.early_stop is not None: 

1422 config.early_stop = args.early_stop 

1423 if args.total_timeout is not None: 

1424 config.total_timeout = args.total_timeout 

1425 if args.phase_timeout is not None: 

1426 config.phase_timeout = args.phase_timeout 

1427 if args.retries is not None: 

1428 config.retries = max(0, args.retries) 

1429 if args.seed is not None: 

1430 config.seed = args.seed 

1431 if args.chair: 

1432 config.chair = args.chair 

1433 if args.verify is not None: 

1434 config.verify = args.verify 

1435 if args.context_mode is not None: 

1436 config.context.mode = args.context_mode 

1437 if args.redact is not None: 

1438 config.context.redact_secrets = args.redact 

1439 if args.max_diff_bytes is not None: 

1440 config.diff.max_bytes = args.max_diff_bytes 

1441 if args.chunk is not None: 

1442 config.diff.chunk = args.chunk 

1443 if args.exclude: 

1444 config.diff.exclude = list(config.diff.exclude) + list(args.exclude) 

1445 if args.include: 

1446 config.diff.include = list(config.diff.include) + list(args.include) 

1447 

1448 try: 

1449 policy = load_policy(args.policy) 

1450 except PolicyError as exc: 

1451 print(f"error: {redact(str(exc))[0]}", file=sys.stderr) 

1452 return 2 

1453 

1454 # Issue mode (issue #221) reviews prose, not a diff, so the PR/diff-only 

1455 # concepts below have no meaning. Reject them up front with a clear message 

1456 # rather than silently ignoring them. 

1457 # Exactly one source (issue #367). Listed rather than pairwise so adding a 

1458 # source cannot quietly skip the check. 

1459 _sources = [ 

1460 ("--pr", args.pr), ("--issue", args.issue), ("--diff-file", args.diff_file), 

1461 ("--commit", getattr(args, "commit", None)), 

1462 ("--commits", getattr(args, "commits", None)), 

1463 ] 

1464 _given = [flag for flag, value in _sources if value] 

1465 if len(_given) > 1: 

1466 raise SystemExit( 

1467 f"error: choose one input source, got {', '.join(_given)}" 

1468 ) 

1469 if args.issue: 

1470 for flag, on in ( 

1471 ("--post-inline", args.post_inline), 

1472 ("--post-progress", args.post_progress), 

1473 ("--label", args.label), 

1474 ("--incremental", args.incremental), 

1475 ): 

1476 if on: 

1477 raise SystemExit( 

1478 f"error: {flag} is not supported with --issue (it is a PR/diff concept)" 

1479 ) 

1480 

1481 # Live progress on the PR (issue #125): a single sticky comment updated at 

1482 # each round/chunk milestone. Opt-in and requires --pr. 

1483 progress = None 

1484 if args.post_progress: 

1485 if not args.pr: 

1486 raise SystemExit("error: --post-progress requires --pr") 

1487 from .github import ProgressReporter 

1488 

1489 progress = ProgressReporter(args.pr, args.repo) 

1490 

1491 def log(msg: str) -> None: 

1492 if not args.quiet: 

1493 print(f"[jury] {msg}", file=sys.stderr) 

1494 if progress is not None and _is_progress_milestone(msg): 

1495 progress.update(msg) 

1496 

1497 # Smart offline fallback: with NO config file and NO usable agent CLI, but a 

1498 # local model server reachable, add a local agent so `jury` just works 

1499 # offline out of the box (issue: easier zero-config). Never overrides an 

1500 # explicit config or a working CLI panel. 

1501 _maybe_add_local_fallback(config, args, log) 

1502 

1503 diff, context = _read_diff(args) 

1504 

1505 # Incremental review (issue #9): when --incremental and a prior jury 

1506 # marker exists, narrow the diff to the range since the last reviewed SHA; 

1507 # otherwise fall back safely to the full diff. The reviewed head SHA is also 

1508 # recorded on the posted summary so a later run can go incremental. 

1509 review_scope = None 

1510 head_sha = "" 

1511 if args.incremental: 

1512 if not args.pr: 

1513 raise SystemExit("error: --incremental requires --pr") 

1514 from . import incremental as inc 

1515 from .github import compare_diff, pr_comment_bodies, pr_head_sha 

1516 

1517 head_sha = pr_head_sha(args.pr, args.repo) 

1518 prev_sha = inc.parse_reviewed_sha(pr_comment_bodies(args.pr, args.repo)) 

1519 mode, reason = inc.decide_review(prev_sha, head_sha) 

1520 if mode == inc.MODE_INCREMENTAL: 

1521 inc_diff = compare_diff(prev_sha, head_sha, args.repo) 

1522 if inc_diff.strip(): 

1523 diff = inc_diff 

1524 else: 

1525 mode, reason = inc.MODE_FULL, "incremental range unavailable — full review" 

1526 review_scope = inc.scope_note(mode, reason) 

1527 log(reason) 

1528 

1529 if not diff.strip(): 

1530 raise SystemExit("error: empty diff — nothing to review") 

1531 

1532 # Risk-aware auto-depth (issue #120): scale rounds/verify to the diff when 

1533 # enabled. Explicit --rounds/--verify/--early-stop always win; the panel is 

1534 # never trimmed. Off unless --auto or [jury] auto_depth. 

1535 if args.auto if args.auto is not None else config.auto_depth: 

1536 from .diffprofile import depth_for, describe, profile_diff 

1537 

1538 prof = profile_diff(diff) 

1539 rounds, verify, early_stop = depth_for(prof.risk) 

1540 if args.rounds is None: 

1541 config.rounds = rounds 

1542 if args.early_stop is None: 

1543 config.early_stop = early_stop 

1544 if args.verify is None: 

1545 config.verify = verify 

1546 log(describe(prof)) 

1547 

1548 if getattr(args, "tiered", False): 

1549 config.routing = "tiered" 

1550 if getattr(args, "hints", False): 

1551 config.hints = True 

1552 

1553 if config.hints: 

1554 from .hints import collect_static_hints 

1555 

1556 sh = collect_static_hints() 

1557 if sh: 1557 ↛ 1564line 1557 didn't jump to line 1564 because the condition on line 1557 was always true

1558 context = (context + "\n\n" + sh) if context else sh 

1559 log("injected static analysis hints into review context") 

1560 

1561 # Optional local result cache (issue #33): a hit skips the run entirely; a 

1562 # miss runs the jury and stores the outcome. The key covers the diff, 

1563 # effective config, prompt version, package version, context policy, and seed. 

1564 cache = None 

1565 cache_k = None 

1566 outcome = None 

1567 if args.cache: 

1568 from .cache import Cache, cache_key 

1569 

1570 cache = Cache(args.cache_dir) 

1571 cache_k = cache_key( 

1572 config, diff, mock=args.mock, policy=policy, mode=("issue" if args.issue else "code") 

1573 ) 

1574 outcome = cache.load(cache_k) 

1575 if outcome is not None: 

1576 log(f"cache hit ({cache_k[:12]}…) — reusing stored outcome") 

1577 else: 

1578 log(f"cache miss ({cache_k[:12]}…) — running jury") 

1579 

1580 # Live play-by-play (issue #210, #229): stream each step as it happens. Prints 

1581 # a titled block to stdout the moment a phase result lands. Posting each step to 

1582 # the PR/issue is OPT-IN — it requires BOTH a target (--pr or --issue) AND 

1583 # --post (a bare target only selects the source, never auto-posts), so `--live` 

1584 # alone just streams locally. Posting is best-effort: a GitHub hiccup is logged 

1585 # and never aborts the run. 

1586 live_target = args.pr or args.issue 

1587 # Theater defaults can come from jury.toml (issue #364); the CLI flags 

1588 # (--theater / --no-theater, --theater-style) override per run. Sentinels 

1589 # (None) distinguish "not passed" from an explicit choice. 

1590 theater_on = args.theater if args.theater is not None else config.theater 

1591 theater_style = args.theater_style or config.theater_style 

1592 live_posts = bool((args.live or theater_on) and args.post_summary and live_target) 

1593 live_post = post_issue_comment if args.issue else post_pr_comment 

1594 # Opt-in animated "courtroom" scene (--theater): an interactive TTY view of 

1595 # the REAL run (each model seated, speaking per phase, gavel/vote finale). It 

1596 # needs a wide TTY and an actual run (a cache hit has nothing to replay), so 

1597 # it falls back to the plain --live step stream otherwise. The structured 

1598 # outcome / report / CI gate are untouched — this is a side channel. 

1599 court = None 

1600 if theater_on and outcome is None and not args.quiet: 

1601 from . import theater as _theater 

1602 

1603 if _theater.supports_scene(sys.stdout): 1603 ↛ 1624line 1603 didn't jump to line 1624 because the condition on line 1603 was always true

1604 # Display-only chair label for the scene title. The run resolves the 

1605 # REAL chair internally (resolve_chair needs the usable/reviewer sets 

1606 # and run RNG, which don't exist yet here), so use a best-effort name. 

1607 chair_name = (config.chair if config.chair and config.chair != "rotate" 

1608 else (config.agents[0].name if config.agents else "chair")) 

1609 case = (f"PR #{args.pr}" if args.pr else 

1610 f"issue #{args.issue}" if args.issue else 

1611 f"commit {args.commit}" if getattr(args, "commit", None) else 

1612 f"range {args.commits}" if getattr(args, "commits", None) else 

1613 "local diff") 

1614 court = _theater.Courtroom( 

1615 [(a.name, a.vendor) for a in config.agents], 

1616 chair_name, 

1617 case=case, 

1618 mode=("issue" if args.issue else "code"), 

1619 decision=(args.decision or config.decision), 

1620 style=theater_style, 

1621 ) 

1622 court.open() 

1623 

1624 on_event = None 

1625 if args.live or theater_on: 

1626 

1627 def on_event(kind, result, round_no=None): 

1628 if court is not None: 

1629 court.step(kind, result, round_no) 

1630 else: 

1631 # plain step stream (--live, or --theater fallback off a TTY) 

1632 title, body = render_live_step(kind, result, round_no) 

1633 print(f"## {title}\n\n{body}\n", flush=True) 

1634 if live_posts: 

1635 try: 

1636 title, body = render_live_step(kind, result, round_no) 

1637 live_post(live_target, f"## {title}\n\n{body}", args.repo) 

1638 except Exception as exc: # noqa: BLE001 - best-effort, never crash 

1639 log(f"live: failed to post step to #{live_target}: {redact(str(exc))[0]}") 

1640 

1641 # We stream live only when actually running the jury; a cache hit has nothing 

1642 # to replay, so the consolidated report is still printed in that case. 

1643 live_streamed = bool(args.live or theater_on) and outcome is None 

1644 

1645 if outcome is None: 

1646 try: 

1647 if args.issue: 

1648 # Issue prose bypasses large-diff planning (filter/size/chunk is 

1649 # meaningless for an issue body); run the jury directly with the 

1650 # issue-quality rubric. ``_plan`` stays None — there is no diff plan. 

1651 _plan = None 

1652 outcome = run_jury( 

1653 config, 

1654 diff, 

1655 context=context, 

1656 mock=args.mock, 

1657 strict=args.strict, 

1658 policy=policy, 

1659 log=log, 

1660 on_event=on_event, 

1661 mode="issue", 

1662 ) 

1663 else: 

1664 outcome, _plan = review_diff( 

1665 config, 

1666 diff, 

1667 context=context, 

1668 mock=args.mock, 

1669 strict=args.strict, 

1670 policy=policy, 

1671 log=log, 

1672 on_event=on_event, 

1673 ) 

1674 except KeyboardInterrupt: 

1675 # Graceful cancellation (issue #30): a jury run can be long, so 

1676 # Ctrl-C should exit cleanly with the conventional 130 rather than 

1677 # dumping a traceback. Work already completed is not partially 

1678 # rendered here because the orchestrator returns atomically; we just 

1679 # report the cancellation. 

1680 print("\n[jury] cancelled (interrupted) — no report produced", file=sys.stderr) 

1681 return 130 

1682 except RuntimeError as exc: 

1683 # Large-diff "too large / nothing to review" (issue #31) and "no 

1684 # usable agents" are actionable user errors, not crashes. 

1685 print(f"error: {redact(str(exc))[0]}", file=sys.stderr) 

1686 return 2 

1687 if cache is not None and cache_k is not None: 

1688 cache.store(cache_k, outcome) 

1689 log(f"cached outcome ({cache_k[:12]}…)") 

1690 

1691 # Final-verdict mode (issue #220): a panel vote (tally the reviewers) vs the 

1692 # chair's synthesis. Rendering-only — the outcome is identical; the severity- 

1693 # based CI gate below is unaffected. Effective = CLI flag else config. 

1694 decision = args.decision or config.decision 

1695 vote = None 

1696 if decision == "vote": 

1697 from .voting import is_abstention, tally_votes 

1698 

1699 # A reviewer that abstained (empty reply or a refusal) is excluded from 

1700 # the tally — a non-answer must not count as a "clear" vote (issue #251). 

1701 voters = [ 

1702 r.agent for r in outcome.reviews if r.ok and not is_abstention(getattr(r, "output", "")) 

1703 ] 

1704 vote = tally_votes( 

1705 outcome.groups, 

1706 voters, 

1707 mode=("issue" if args.issue else "code"), 

1708 ) 

1709 

1710 # Close the courtroom scene (after the vote is tallied, so the panel-vote 

1711 # finale can show the ballots/verdict). 

1712 if court is not None: 

1713 if vote is not None: 

1714 court.set_vote(vote) 

1715 court.close() 

1716 

1717 metadata = build_run_metadata(outcome, config, decision=decision, vote=vote) 

1718 

1719 if args.format == "json": 

1720 from .formats import to_json 

1721 

1722 report = to_json(outcome, config, decision=decision, vote=vote) 

1723 elif args.format == "sarif": 

1724 from .formats import to_sarif 

1725 

1726 report = to_sarif(outcome, config) 

1727 else: 

1728 # Output mode (issue: full transcript). --verbose => summary + transcript; 

1729 # --transcript (or [jury] transcript, unless --no-transcript) => the 

1730 # chronological play-by-play; otherwise the consensus-first summary. 

1731 # Rendering-only — the orchestration/outcome is identical either way. 

1732 transcript_default = args.transcript if args.transcript is not None else config.transcript 

1733 if args.verbose or transcript_default: 

1734 report = render_transcript( 

1735 outcome.reviews, 

1736 outcome.debate, 

1737 outcome.synthesis, 

1738 chair=outcome.chair, 

1739 findings=outcome.findings, 

1740 warnings=outcome.warnings, 

1741 groups=outcome.groups, 

1742 verify=outcome.verify, 

1743 context_mode=outcome.context_mode, 

1744 redact_secrets=outcome.redact_secrets, 

1745 redaction_count=outcome.redaction_count, 

1746 metadata=metadata, 

1747 review_scope=review_scope, 

1748 lead_with_summary=bool(args.verbose), 

1749 vote=vote, 

1750 ) 

1751 else: 

1752 report = render( 

1753 outcome.reviews, 

1754 outcome.debate, 

1755 outcome.synthesis, 

1756 chair=outcome.chair, 

1757 findings=outcome.findings, 

1758 warnings=outcome.warnings, 

1759 groups=outcome.groups, 

1760 verify=outcome.verify, 

1761 context_mode=outcome.context_mode, 

1762 redact_secrets=outcome.redact_secrets, 

1763 redaction_count=outcome.redaction_count, 

1764 metadata=metadata, 

1765 review_scope=review_scope, 

1766 vote=vote, 

1767 ) 

1768 

1769 if args.metadata_json: 

1770 with Path(args.metadata_json).open("w", encoding="utf-8") as fh: 

1771 fh.write(json.dumps(metadata, indent=2) + "\n") 

1772 log(f"metadata written to {args.metadata_json}") 

1773 

1774 ci_exit = 0 

1775 if args.ci: 

1776 fail_on = config.ci.fail_on 

1777 if args.fail_on: 

1778 fail_on = [s.strip().lower() for s in args.fail_on.split(",") if s.strip()] 

1779 ci_exit, ci_reason = evaluate_ci(outcome.groups, fail_on, config.ci.ignore_unverified) 

1780 # Only the markdown report carries the human-readable CI gate section; 

1781 # json/sarif documents stay machine-clean. The exit code is unchanged. 

1782 if args.format == "markdown": 

1783 report += f"\n\n## CI gate\n\n{ci_reason}\n" 

1784 

1785 # Suggested patches (issue #10): opt-in and kept separate from the default 

1786 # report. Written to a file with --patches-out, else appended after the 

1787 # markdown report under its own heading. The default flow stays read-only. 

1788 if args.suggest_patches: 

1789 from .patches import render_patch_suggestions 

1790 

1791 patches_section = render_patch_suggestions(outcome.groups) 

1792 if not patches_section: 

1793 log("no verified findings with a suggested fix — no patches emitted") 

1794 elif args.patches_out: 

1795 Path(args.patches_out).write_text(patches_section, encoding="utf-8") 

1796 log(f"suggested patches written to {args.patches_out}") 

1797 elif args.format == "markdown": 

1798 report += "\n\n" + patches_section.rstrip() 

1799 else: 

1800 log("--suggest-patches needs markdown output or --patches-out; skipped") 

1801 

1802 # Turn the live progress comment into the final verdict (issue #125). 

1803 if progress is not None: 

1804 progress.finish(report) 

1805 log(f"progress comment finalized on PR #{args.pr}") 

1806 

1807 if args.output: 

1808 with Path(args.output).open("w", encoding="utf-8") as fh: 

1809 fh.write(report + "\n") 

1810 log(f"report written to {args.output}") 

1811 elif not (live_streamed and args.format == "markdown"): 

1812 # In --live markdown mode the step stream WAS the stdout output; don't also 

1813 # dump the consolidated report (it would duplicate everything just shown). 

1814 # For json/sarif the stream is human-readable markdown, so the requested 

1815 # machine-readable document must still go to stdout. 

1816 print(report) 

1817 

1818 if args.post_summary: 

1819 if args.issue: 

1820 # Plain issues use `gh issue comment`; phased/SHA-marker posting is 

1821 # PR-only, so the issue path posts the single rendered report. 

1822 post_issue_comment(args.issue, report, args.repo) 

1823 log(f"posted verdict to issue #{args.issue}") 

1824 return ci_exit 

1825 if not args.pr: 

1826 raise SystemExit("error: --post-summary requires --pr") 

1827 # Record the reviewed head SHA as a hidden marker so a later 

1828 # --incremental run can review only the new range (issue #9). 

1829 from .github import pr_head_sha 

1830 from .incremental import reviewed_sha_marker 

1831 

1832 marker_sha = head_sha or pr_head_sha(args.pr, args.repo) 

1833 marker = f"\n\n{reviewed_sha_marker(marker_sha)}" if marker_sha else "" 

1834 

1835 if args.post_mode == "phased": 

1836 # Post the flow as separate, readable comments (issue #127): 

1837 # Round 1 → debate → decision. The SHA marker rides the last one. 

1838 from .report import render_sections 

1839 

1840 sections = render_sections( 

1841 outcome.reviews, 

1842 outcome.debate, 

1843 outcome.synthesis, 

1844 chair=outcome.chair, 

1845 findings=outcome.findings, 

1846 warnings=outcome.warnings, 

1847 groups=outcome.groups, 

1848 verify=outcome.verify, 

1849 vote=vote, 

1850 ) 

1851 for i, (title, body) in enumerate(sections): 

1852 tail = marker if i == len(sections) - 1 else "" 

1853 post_pr_comment(args.pr, f"## {title}\n\n{body}{tail}", args.repo) 

1854 log(f"posted {len(sections)} phased comments to PR #{args.pr}") 

1855 else: 

1856 post_pr_comment(args.pr, f"{report}{marker}", args.repo) 

1857 log(f"posted verdict to PR #{args.pr}") 

1858 

1859 if args.post_inline: 

1860 if not args.pr: 

1861 raise SystemExit("error: --post-inline requires --pr") 

1862 post_inline_comments(args.pr, outcome.findings, repo=args.repo, dry_run=args.dry_run) 

1863 log(f"posted inline comments to PR #{args.pr}") 

1864 

1865 # Optional GitHub labels (issue #7): OFF by default. Only applied when 

1866 # --label is passed AND a --pr target exists; never automatic. 

1867 if args.label: 

1868 if not args.pr: 

1869 raise SystemExit("error: --label requires --pr") 

1870 labels = label_strings(classify(outcome)) 

1871 apply_labels(args.pr, labels, args.repo) 

1872 log(f"applied labels to PR #{args.pr}: {', '.join(labels)}") 

1873 

1874 return ci_exit 

1875 

1876 

1877if __name__ == "__main__": 

1878 raise SystemExit(main())