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
« prev ^ index » next coverage.py v7.15.4, created at 2026-08-17 21:31 +0000
1"""Command-line entry point: ``jury``.
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"""
13from __future__ import annotations
15import argparse
16import contextlib
17import io
18import json
19import sys
20from pathlib import Path
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
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
50def _read_capped(fh, source: str) -> str:
51 """Read from ``fh``, refusing inputs above the ingest ceiling.
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")
74def _checked_revision(value: str, flag: str) -> str:
75 """Reject a revision that cannot safely reach ``git``'s argv (issue #367).
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
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
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)
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 )
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 )
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
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
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)
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
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
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
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
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.
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
572 from .commands import CommandError, parse_comment
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)
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
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]
606 if ns.print_args:
607 print(" ".join(shlex.quote(a) for a in inner))
608 return 0
609 return main(inner)
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}
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
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
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.
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
652 if models_fn is None:
653 from .adapters import list_local_models as models_fn
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
663 rounds_raw = input_fn("Rounds — 1=review, 2=+debate [2]: ").strip()
664 rounds = int(rounds_raw) if rounds_raw.isdigit() else 2
666 chair_default = agents[0] if agents else "claude"
667 chair = input_fn(f"Chair agent [{chair_default}]: ").strip() or chair_default
669 verify = (input_fn("Run verification round? [Y/n]: ").strip().lower() or "y") != "n"
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
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
697 return {
698 "agents": agents,
699 "rounds": rounds,
700 "chair": chair,
701 "verify": verify,
702 "local_model": local_model,
703 }
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).
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
717 if models_fn is None:
718 from .adapters import list_local_models as models_fn
720 def ask(prompt: str) -> str:
721 return input_fn(prompt).strip()
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
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 )
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
751 kwargs: dict = {"agents": agents}
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
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"
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"
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
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"] = []
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
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
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
839 return kwargs
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
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)
878 from .adapters import list_local_models
879 from .redaction import redact_url_userinfo
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)
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
897 available = _init_available()
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
909 preset = PRESETS.get(ns.preset, {})
911 def _detected_agents():
912 return [n for n in KNOWN_AGENTS if available.get(n)]
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)
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")
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 }
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
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
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
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
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)"
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)
1038def _run_config(rest: list[str]) -> int:
1039 """Handle ``jury config show|path``."""
1040 from .config import ConfigError, load_config
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)
1047 source = _config_source(ns.config)
1048 if ns.action == "path":
1049 print(source)
1050 return 0
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
1062def _run_replay(rest: list[str]) -> int:
1063 """Handle ``jury replay <outcome.json>`` (issue #449).
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
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)
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
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
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)
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
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 )
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
1161_PROGRESS_PREFIXES = (
1162 "round ",
1163 "reviewing chunk",
1164 "verification",
1165 "synthesis",
1166 "diff size",
1167 "early stop",
1168 "auto-depth",
1169)
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)
1177def _maybe_add_local_fallback(config, args, log) -> None:
1178 """Append a local agent when nothing else can run, offline (issue: zero-config).
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
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)")
1207def _force_utf8_output() -> None:
1208 """Ensure stdout/stderr can emit the report's Unicode (emoji, arrows).
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")
1223_OVERVIEW = """\
1224🏛️ ai-jury — a cross-vendor multi-agent review jury.
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.
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
1239Docs: https://github.com/berkayturanci/ai-jury"""
1241_EXAMPLES = """\
1242ai-jury — example commands
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
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)
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)
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
1266Run `jury guide` for a walkthrough, or `jury --help` for every option."""
1268_GUIDE = """\
1269ai-jury — a short walkthrough
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
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.
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
1285 The panel reviews independently, cross-examines (debate), the chair verifies
1286 candidate findings to cut false positives, then synthesizes one verdict.
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
12925. Gate CI on blocking findings (optional):
1293 jury --pr 123 --ci # non-zero exit on critical/major
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"""
1300def main(argv: list[str] | None = None) -> int:
1301 _force_utf8_output()
1302 raw = list(sys.argv[1:] if argv is None else argv)
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
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
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
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:])
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:])
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:])
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:])
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:])
1369 args = build_parser().parse_args(argv)
1371 if args.clear_cache:
1372 from .cache import Cache
1374 removed = Cache(args.cache_dir).clear()
1375 print(f"Cleared {removed} cache entr{'y' if removed == 1 else 'ies'}.")
1376 return 0
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
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
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)
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
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 )
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
1489 progress = ProgressReporter(args.pr, args.repo)
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)
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)
1503 diff, context = _read_diff(args)
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
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)
1529 if not diff.strip():
1530 raise SystemExit("error: empty diff — nothing to review")
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
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))
1548 if getattr(args, "tiered", False):
1549 config.routing = "tiered"
1550 if getattr(args, "hints", False):
1551 config.hints = True
1553 if config.hints:
1554 from .hints import collect_static_hints
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")
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
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")
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
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()
1624 on_event = None
1625 if args.live or theater_on:
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]}")
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
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]}…)")
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
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 )
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()
1717 metadata = build_run_metadata(outcome, config, decision=decision, vote=vote)
1719 if args.format == "json":
1720 from .formats import to_json
1722 report = to_json(outcome, config, decision=decision, vote=vote)
1723 elif args.format == "sarif":
1724 from .formats import to_sarif
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 )
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}")
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"
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
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")
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}")
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)
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
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 ""
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
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}")
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}")
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)}")
1874 return ci_exit
1877if __name__ == "__main__":
1878 raise SystemExit(main())