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

507 statements  

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

1"""Jury orchestration: review -> debate -> synthesis. 

2 

3The orchestrator owns the round structure and prompt assembly; adapters only run 

4their CLI. Rounds run agents concurrently (thread pool) because each call is an 

5independent, IO-bound subprocess. 

6""" 

7 

8from __future__ import annotations 

9 

10import random 

11import string 

12import time 

13from concurrent.futures import ThreadPoolExecutor 

14from dataclasses import dataclass, field, replace 

15 

16from . import convergence, injection, largediff, prompts 

17from .adapters import RETRYABLE_ERROR_CODES, Adapter, AgentResult, make_adapter 

18from .config import JuryConfig 

19from .consensus import FindingGroup, demote_local_only_groups, group_findings 

20from .findings import ( 

21 Finding, 

22 Verdict, 

23 emitted_findings_block, 

24 parse_findings, 

25 parse_verdicts, 

26) 

27from .policy import ReviewPolicy, render_policy_section 

28from .privilege import audit_privilege 

29from .redaction import redact 

30 

31 

32class RunBudget: 

33 """Wall-clock budget for one jury run (issue #30). 

34 

35 Tracks the elapsed time since construction and derives the timeout to pass a 

36 single agent call from the optional total-run and per-phase budgets. ``None`` 

37 for either budget means uncapped; when both are unset ``call_timeout`` 

38 returns ``None`` so adapters fall back to their own per-agent timeout and 

39 behaviour is identical to having no budget at all. 

40 """ 

41 

42 def __init__(self, total_timeout: int | None, phase_timeout: int | None): 

43 self.total = total_timeout 

44 self.phase = phase_timeout 

45 self._start = time.monotonic() 

46 

47 def elapsed(self) -> float: 

48 return time.monotonic() - self._start 

49 

50 def remaining(self) -> float | None: 

51 if self.total is None: 

52 return None 

53 return max(0.0, self.total - self.elapsed()) 

54 

55 def expired(self) -> bool: 

56 return self.total is not None and self.elapsed() >= self.total 

57 

58 def call_timeout(self) -> int | None: 

59 """Per-call timeout: the min of the phase budget and remaining total. 

60 

61 The agent's own per-agent timeout is applied by the adapter (it takes the 

62 min with this value), so it is not needed here. Returns ``None`` when 

63 neither budget caps the call, leaving the adapter to use its configured 

64 per-agent timeout. 

65 """ 

66 caps: list[float] = [] 

67 if self.phase is not None: 

68 caps.append(float(self.phase)) 

69 remaining = self.remaining() 

70 if remaining is not None: 

71 caps.append(remaining) 

72 if not caps: 

73 return None 

74 return max(1, int(min(caps))) 

75 

76 

77def _run_with_retry( 

78 adapter: Adapter, 

79 prompt: str, 

80 phase: str, 

81 budget: RunBudget, 

82 retries: int, 

83 log, 

84) -> AgentResult: 

85 """Run one agent for one phase, retrying transient failures (issue #30). 

86 

87 Retries only failures whose typed error code is in 

88 ``RETRYABLE_ERROR_CODES`` (timeout/rate-limit/spawn), up to ``retries`` extra 

89 attempts. A deterministic failure (auth, missing CLI, empty output, generic 

90 nonzero exit) is returned immediately. The returned result's ``attempts`` 

91 records how many tries were made. Retrying stops early when the run budget is 

92 exhausted so a retry never overruns the total timeout. 

93 """ 

94 max_attempts = max(1, retries + 1) 

95 result = adapter.run(prompt, phase=phase, timeout=budget.call_timeout()) 

96 attempts = 1 

97 while ( 

98 not result.ok 

99 and result.error_code in RETRYABLE_ERROR_CODES 

100 and attempts < max_attempts 

101 and not budget.expired() 

102 ): 

103 log(f"{adapter.name}: {phase} attempt {attempts} failed ({result.error_code}); retrying") 

104 result = adapter.run(prompt, phase=phase, timeout=budget.call_timeout()) 

105 attempts += 1 

106 result.attempts = attempts 

107 return result 

108 

109 

110def _order_by_agents(results: list[AgentResult], order: list[str]) -> list[AgentResult]: 

111 """Reorder phase results into the configured/enabled agent order. 

112 

113 Round phases run agents concurrently (ThreadPoolExecutor.map), so the order 

114 in which results arrive is not guaranteed across runs. The report and all 

115 downstream consumers must NOT depend on thread-completion order, so we sort 

116 every phase's results by each agent's index in ``order`` (the stable 

117 enabled-agent list). Agents not present in ``order`` (should not happen) 

118 sort to the end, preserving their relative arrival order as a stable 

119 tiebreak so the sort is total and deterministic. 

120 """ 

121 index = {name: i for i, name in enumerate(order)} 

122 fallback = len(order) 

123 return sorted(results, key=lambda r: index.get(r.agent, fallback)) 

124 

125 

126@dataclass 

127class JuryOutcome: 

128 reviews: list[AgentResult] 

129 debate: list[AgentResult] 

130 synthesis: AgentResult | None 

131 chair: str 

132 findings: list[Finding] = field(default_factory=list) 

133 warnings: list[str] = field(default_factory=list) 

134 groups: list[FindingGroup] = field(default_factory=list) 

135 verify: AgentResult | None = None 

136 verdicts: list[Verdict] = field(default_factory=list) 

137 context_mode: str = "diff-only" 

138 redact_secrets: bool = True 

139 redaction_count: int = 0 

140 injection_hits: list = field(default_factory=list) 

141 # Execution/partial-result signals (issue #30): agents skipped because their 

142 # CLI was unavailable (name, reason), and whether the run budget was 

143 # exhausted before all phases completed. 

144 skipped: list = field(default_factory=list) 

145 budget_exhausted: bool = False 

146 # Adaptive-rounds signals (issue #40): rounds actually executed and a short 

147 # human-readable reason for why the debate ran / stopped. 

148 rounds_executed: int = 1 

149 stop_reason: str = "" 

150 # Set when this outcome was served from the local result cache (issue #33), 

151 # so the report/metadata can mark it as cached rather than freshly computed. 

152 from_cache: bool = False 

153 

154 

155def _run_phase( 

156 adapters: list[Adapter], 

157 prompt_for: dict[str, str], 

158 phase: str, 

159 parallel: bool, 

160 *, 

161 budget: RunBudget, 

162 retries: int, 

163 log, 

164) -> list[AgentResult]: 

165 def task(a: Adapter) -> AgentResult: 

166 return _run_with_retry(a, prompt_for[a.name], phase, budget, retries, log) 

167 

168 if parallel and len(adapters) > 1: 

169 with ThreadPoolExecutor(max_workers=len(adapters)) as pool: 

170 return list(pool.map(task, adapters)) 

171 return [task(a) for a in adapters] 

172 

173 

174def _others(reviews: list[AgentResult], me: str) -> str: 

175 """Identity-labeled peer reviews (legacy path; ``anonymize_debate = false``). 

176 

177 Renders each *other* reviewer's round-1 output with its real agent/vendor 

178 identity in the stable enabled-agent order. This is the pre-#37 behaviour and 

179 leaks both identity and position; the anonymizing path below is the default. 

180 """ 

181 chunks = [ 

182 f"### {r.agent} ({r.vendor})\n{r.output}" 

183 for r in reviews 

184 if r.agent != me and r.ok and r.output 

185 ] 

186 return "\n\n".join(chunks) if chunks else "_(no other reviews available)_" 

187 

188 

189def _anon_label(i: int) -> str: 

190 """Stable anonymous reviewer label: 0->'A', 1->'B', ... 26->'AA'.""" 

191 letters = string.ascii_uppercase 

192 label = "" 

193 i += 1 

194 while i > 0: 

195 i, rem = divmod(i - 1, 26) 

196 label = letters[rem] + label 

197 return label 

198 

199 

200def _anonymize_peers( 

201 reviews: list[AgentResult], me: str, rng: random.Random 

202) -> tuple[str, dict[str, str]]: 

203 """Chatham House peer view for a debater (#37). 

204 

205 Returns ``(prompt_text, label_to_agent)`` where the prompt text renders each 

206 *other* successful reviewer's round-1 output under an anonymous 

207 ``### Reviewer A`` / ``### Reviewer B`` heading — NO vendor or agent name. 

208 The debater's OWN review is excluded (it is passed separately as 

209 ``own_review``). Presentation order is shuffled DETERMINISTICALLY using the 

210 shared run RNG so neither identity nor position is a stable signal; the same 

211 seed yields the same order, different seeds may differ. 

212 

213 ``label_to_agent`` keeps the anonymous-label -> real-agent mapping internal so 

214 callers can still recover authorship (the report attributes by real name). 

215 """ 

216 peers = [r for r in reviews if r.agent != me and r.ok and r.output] 

217 if not peers: 

218 return "_(no other reviews available)_", {} 

219 # Deterministic per-debater shuffle from the shared run RNG. We shuffle a 

220 # copy so the caller's review list (used elsewhere) is untouched. 

221 order = list(peers) 

222 rng.shuffle(order) 

223 chunks: list[str] = [] 

224 label_to_agent: dict[str, str] = {} 

225 for i, r in enumerate(order): 

226 label = f"Reviewer {_anon_label(i)}" 

227 label_to_agent[label] = r.agent 

228 chunks.append(f"### {label}\n{r.output}") 

229 return "\n\n".join(chunks), label_to_agent 

230 

231 

232def _debate_round( 

233 debaters: list[Adapter], 

234 reviews: list[AgentResult], 

235 diff: str, 

236 config: JuryConfig, 

237 run_rng: random.Random, 

238 agent_order: list[str], 

239 prior: list[AgentResult], 

240 budget: RunBudget, 

241 retries: int, 

242 log, 

243 round_no: int, 

244 template: str = prompts.DEBATE, 

245) -> list[AgentResult]: 

246 """Run one debate round and return its results in stable agent order. 

247 

248 ``prior`` holds the previous round's debate outputs (empty for the first 

249 debate round); when present they are appended to each debater's prompt as a 

250 "prior debate" addendum so later rounds in an adaptive run (issue #40) build 

251 on, rather than repeat, earlier cross-examination. The peer-review anonymizing 

252 path (#37) is preserved unchanged. 

253 """ 

254 log(f"round {round_no}: {len(debaters)} agents cross-examining") 

255 own = {r.agent: r.output for r in reviews if r.ok} 

256 # Prior-round debate output quotes attacker-controlled diff text, so it is 

257 # untrusted: neutralize sentinels (issue #316/L-1) before it is fenced and 

258 # appended below, matching every other peer-output slot. 

259 prior_txt = prompts.neutralize_sentinels( 

260 "\n\n".join(f"### {r.agent}\n{r.output}" for r in prior if r.ok and r.output) 

261 ) 

262 debate_prompt: dict[str, str] = {} 

263 for a in debaters: 

264 if config.anonymize_debate: 

265 # Per-debater deterministic shuffle: derive a child RNG from the 

266 # shared run RNG so each debater gets an independent but reproducible 

267 # peer ordering (same seed -> same order). 

268 peer_rng = random.Random(run_rng.random()) 

269 other_reviews, _label_map = _anonymize_peers(reviews, a.name, peer_rng) 

270 else: 

271 other_reviews = _others(reviews, a.name) 

272 text = template.format( 

273 name=a.name, 

274 diff=prompts.neutralize_sentinels(diff), 

275 own_review=prompts.neutralize_sentinels( 

276 own.get(a.name, "_(your review was unavailable)_") 

277 ), 

278 other_reviews=prompts.neutralize_sentinels(other_reviews), 

279 notice=prompts._UNTRUSTED_NOTICE, 

280 ) 

281 if prior_txt: 

282 text += ( 

283 "\n\n=== PRIOR DEBATE (earlier round) ===\n" 

284 "Build on this; do not just repeat it. Only keep a DISPUTE or " 

285 "MISSED item if it is still unresolved.\n\n" 

286 "<<<UNTRUSTED_REVIEW\n" + prior_txt + "\nUNTRUSTED_REVIEW>>>\n" 

287 ) 

288 debate_prompt[a.name] = text 

289 results = _run_phase( 

290 debaters, 

291 debate_prompt, 

292 "debate", 

293 config.parallel, 

294 budget=budget, 

295 retries=retries, 

296 log=log, 

297 ) 

298 # Same stable-ordering guarantee as round 1: independent of thread-pool 

299 # completion order. 

300 return _order_by_agents(results, agent_order) 

301 

302 

303def run_jury( 

304 config: JuryConfig, 

305 diff: str, 

306 *, 

307 context: str = "", 

308 mock: bool = False, 

309 strict: bool = False, 

310 seed: int | None = None, 

311 policy: ReviewPolicy | None = None, 

312 log=lambda _msg: None, 

313 budget: RunBudget | None = None, 

314 on_event=None, 

315 mode: str = "code", 

316) -> JuryOutcome: 

317 # Jury mode (issue #221): "code" (default) reviews a diff with the code-review 

318 # rubric; "issue" reviews a GitHub issue's prose for completeness/clarity. 

319 # Only the prompt TEMPLATES differ — the round structure, consensus, voting, 

320 # verification, ordering, and determinism are identical. ``tmpl`` selects the 

321 # four phase templates; each is threaded into the phase that uses it so the 

322 # call sites are otherwise unchanged. 

323 tmpl = prompts.for_mode(mode) 

324 # Live play-by-play hook (issue #210): an optional callback fired after each 

325 # phase result is produced — ``on_event(kind, result, round_no=None)`` with 

326 # kind in {"review", "debate", "verify", "synthesis"}. It lets a caller stream 

327 # the deliberation as it happens (CLI ``--live``) without the orchestrator 

328 # doing any I/O itself. Fired in stable per-phase order (not thread-completion 

329 # order) so the event sequence is deterministic. Defaults to a no-op. 

330 emit = on_event or (lambda *_a, **_k: None) 

331 # Repository review policy (optional, #8): maintainer-authored, TRUSTED 

332 # content rendered into each REVIEW prompt in a clearly separated section. 

333 # When ``policy`` is None a sentinel placeholder is used, so the prompt is 

334 # unchanged except for that section. The policy is distinct from the 

335 # agent-runtime ``config`` and never enters the untrusted diff/context fences. 

336 policy_section = render_policy_section(policy) 

337 # Run reproducibility: a single shared RNG seeds every randomized 

338 # orchestration decision (future: anonymized-rebuttal order, rotating 

339 # chair, tie-breaks). The seed comes from the explicit ``seed`` argument if 

340 # given, else from ``config.seed``. We construct a dedicated 

341 # ``random.Random`` instance rather than touching the global ``random`` 

342 # module so seeding a jury run never perturbs unrelated global state. 

343 # When the seed is None the RNG is unseeded (still deterministic 

344 # orchestration; randomness, if any, is just not reproducible run-to-run). 

345 # LLM output itself is never made deterministic by this — only the 

346 # orchestration around it. ``run_rng`` is the shared run RNG: pass it to 

347 # any feature that needs reproducible randomness instead of using ``random``. 

348 run_seed = seed if seed is not None else config.seed 

349 run_rng = random.Random(run_seed) # shared run RNG (see docstring) 

350 

351 # Run budget (issue #30): a single wall-clock budget threaded through every 

352 # phase. Defaults (both None) leave behaviour identical to no budget, with 

353 # each agent bounded only by its own per-agent timeout. ``retries`` is the 

354 # number of extra attempts for transient (retryable) failures. A caller may 

355 # pass a SHARED budget so ``total_timeout`` spans a whole chunked review 

356 # rather than resetting per chunk (issue #31 / review finding). 

357 if budget is None: 

358 budget = RunBudget(config.total_timeout, config.phase_timeout) 

359 retries = config.retries 

360 

361 # Context policy: diff-only sends only the diff; expanded includes context. 

362 ctx_cfg = getattr(config, "context", None) 

363 context_mode = getattr(ctx_cfg, "mode", "diff-only") if ctx_cfg else "diff-only" 

364 redact_on = getattr(ctx_cfg, "redact_secrets", True) if ctx_cfg else True 

365 if context_mode == "diff-only": 

366 context = "" 

367 redaction_count = 0 

368 if redact_on: 

369 diff, _n1 = redact(diff) 

370 context, _n2 = redact(context) 

371 redaction_count = _n1 + _n2 

372 if redaction_count: 

373 log(f"redacted {redaction_count} secret(s) before sending to agents") 

374 

375 # Prompt-injection heuristic (OWASP LLM01): scan untrusted diff/context for 

376 # patterns that try to override instructions, then SURFACE them as a synthetic 

377 # finding/warning. We never act on them; the CI gate is derived from 

378 # structured consensus (see ci.evaluate_ci), so an injected "APPROVE" 

379 # cannot flip the verdict. 

380 injection_hits = injection.scan_inputs(diff, context) 

381 injection_findings: list[Finding] = [] 

382 if injection_hits: 

383 log(f"prompt-injection heuristic: {len(injection_hits)} suspicious pattern(s) flagged") 

384 syn = injection.hits_to_finding(injection_hits) 

385 if syn is not None: 385 ↛ 390line 385 didn't jump to line 390 because the condition on line 385 was always true

386 injection_findings.append(syn) 

387 

388 # Least-privilege audit: warn when a configured agent could perform 

389 # write/tool actions while reviewing attacker-controlled content. 

390 privilege_warnings = audit_privilege(config.enabled_agents) 

391 for w in privilege_warnings: 

392 log(f"least-privilege warning: {w}") 

393 if strict and privilege_warnings: 

394 raise RuntimeError( 

395 "least-privilege check failed (--strict): " + "; ".join(privilege_warnings) 

396 ) 

397 

398 specs = config.enabled_agents 

399 adapters = [make_adapter(s, mock=mock) for s in specs] 

400 

401 # Filter to available agents (unless strict, where a missing CLI is fatal). 

402 # Skipped agents are recorded (name, reason) so the report can state exactly 

403 # which agents never ran — part of the partial-result policy (issue #30). 

404 usable: list[Adapter] = [] 

405 skipped: list[tuple[str, str]] = [] 

406 for a in adapters: 

407 if a.available(): 

408 usable.append(a) 

409 elif strict: 

410 raise RuntimeError(f"agent '{a.name}' CLI not available: {a.spec.command}") 

411 else: 

412 reason = f"CLI not found ({a.spec.command})" 

413 log(f"skipping '{a.name}': {reason}") 

414 skipped.append((a.name, reason)) 

415 if not usable: 

416 raise RuntimeError("no usable agents — install at least one agent CLI or use --mock") 

417 

418 usable_names = [a.name for a in usable] 

419 

420 # Round 1: independent reviews. 

421 log(f"round 1: {len(usable)} agents reviewing") 

422 review_prompt = { 

423 a.name: tmpl["review"].format( 

424 name=a.name, 

425 context=prompts.neutralize_sentinels(context or "_(none)_"), 

426 diff=prompts.neutralize_sentinels(diff), 

427 policy=policy_section, 

428 notice=prompts._UNTRUSTED_NOTICE, 

429 ) 

430 for a in usable 

431 } 

432 reviews = _run_phase( 

433 usable, 

434 review_prompt, 

435 "review", 

436 config.parallel, 

437 budget=budget, 

438 retries=retries, 

439 log=log, 

440 ) 

441 # Stable ordering: the thread pool can return results in any completion 

442 # order. Reorder to the enabled-agent order so the report (and every 

443 # downstream consumer) is independent of which thread finished first. 

444 agent_order = [a.name for a in usable] 

445 reviews = _order_by_agents(reviews, agent_order) 

446 

447 # Parse structured findings from each successful review and aggregate them. 

448 # Seed with the synthetic injection finding/warnings so they surface in the 

449 # report and outcome.warnings without ever influencing agent behaviour. 

450 all_findings: list[Finding] = list(injection_findings) 

451 all_warnings: list[str] = injection.hits_to_warnings(injection_hits) 

452 all_warnings.extend(privilege_warnings) 

453 for r in reviews: 

454 if not r.ok: 

455 continue 

456 found, warns = parse_findings(r.output, r.agent) 

457 r.findings = found 

458 r.warnings = warns 

459 r.structured = emitted_findings_block(r.output) 

460 all_findings.extend(found) 

461 all_warnings.extend(warns) 

462 

463 # Stream round-1 reviews as they're now finalized (stable order). 

464 for r in reviews: 

465 emit("review", r) 

466 

467 # Deterministic consensus grouping across reviewers. 

468 groups = group_findings(all_findings, len(reviews)) 

469 

470 # Names of agents whose round-1 review succeeded — the chair resolver uses 

471 # this to (optionally) prefer a non-reviewer chair (#38). 

472 reviewer_names = [r.agent for r in reviews if r.ok] 

473 

474 # Resolve the chair ONCE for the whole run so verify and synthesis use the 

475 # SAME chair. ``chair = "rotate"`` and prefer-non-reviewer both consume the 

476 # shared run RNG / reviewer info, so resolving once (rather than recomputing 

477 # per phase) is what keeps a rotating chair stable within a run (#38). 

478 chair_name = resolve_chair(config, usable_names, reviewer_names, run_rng) 

479 

480 # Round 2+: debate. Only agents whose round-1 review succeeded participate. 

481 # Two modes (issue #40): 

482 # - fixed (early_stop = false): honour ``rounds`` exactly — run one debate 

483 # round iff rounds >= 2. Reproducible fixed-N behaviour for benchmarking. 

484 # - adaptive (early_stop = true): skip the debate when round-1 reviewers 

485 # already agree, otherwise run debate up to ``max_rounds`` rounds and stop 

486 # as soon as a round resolves all disputes. 

487 debate: list[AgentResult] = [] 

488 rounds_executed = 1 

489 stop_reason = "" 

490 budget_exhausted = False 

491 debaters = [a for a in usable if any(r.agent == a.name and r.ok for r in reviews)] 

492 can_debate = len(debaters) >= 2 

493 

494 if config.early_stop: 

495 max_rounds = config.effective_max_rounds 

496 if not can_debate: 

497 stop_reason = "stopped after round 1: need >=2 successful reviews to debate" 

498 log(stop_reason) 

499 elif max_rounds < 2: 

500 stop_reason = "stopped after round 1: max_rounds < 2" 

501 log(stop_reason) 

502 else: 

503 converged, why = convergence.review_convergence(groups, len(reviews)) 

504 if converged: 

505 stop_reason = f"early stop after round 1: {why}" 

506 log(stop_reason) 

507 else: 

508 log(f"early stop active: {why}; running debate up to {max_rounds} round(s)") 

509 prior: list[AgentResult] = [] 

510 round_no = 1 

511 while round_no < max_rounds: 

512 if budget.expired(): 

513 budget_exhausted = True 

514 stop_reason = f"stopped at round {rounds_executed}: run budget exhausted" 

515 log(stop_reason) 

516 break 

517 round_no += 1 

518 debate = _debate_round( 

519 debaters, 

520 reviews, 

521 diff, 

522 config, 

523 run_rng, 

524 agent_order, 

525 prior, 

526 budget, 

527 retries, 

528 log, 

529 round_no, 

530 template=tmpl["debate"], 

531 ) 

532 rounds_executed = round_no 

533 for r in debate: 

534 emit("debate", r, round_no) 

535 dconv, dwhy = convergence.debate_convergence(debate) 

536 if dconv: 

537 stop_reason = f"converged after round {round_no}: {dwhy}" 

538 log(stop_reason) 

539 break 

540 prior = debate 

541 stop_reason = f"ran {round_no} rounds: {dwhy}" 

542 else: 

543 stop_reason = stop_reason or ( 

544 f"reached max_rounds ({max_rounds}) with disagreement remaining" 

545 ) 

546 else: 

547 # Fixed-N: exactly the historical behaviour. 

548 if config.rounds >= 2 and can_debate: 

549 if budget.expired(): 

550 budget_exhausted = True 

551 stop_reason = "round 2 skipped: run budget exhausted" 

552 log(stop_reason) 

553 else: 

554 debate = _debate_round( 

555 debaters, 

556 reviews, 

557 diff, 

558 config, 

559 run_rng, 

560 agent_order, 

561 [], 

562 budget, 

563 retries, 

564 log, 

565 2, 

566 template=tmpl["debate"], 

567 ) 

568 rounds_executed = 2 

569 for r in debate: 

570 emit("debate", r, 2) 

571 elif config.rounds >= 2: 

572 stop_reason = "round 2 skipped: need >=2 successful reviews to debate" 

573 log(stop_reason) 

574 else: 

575 stop_reason = "single round (rounds = 1)" 

576 

577 # Verification: the chair judges candidate findings to reduce false 

578 # positives. Skipped when the run budget is exhausted (issue #30) so a 

579 # partial run still returns what completed instead of overrunning. 

580 verify_result: AgentResult | None = None 

581 verdicts: list[Verdict] = [] 

582 if config.verify: 

583 if budget.expired(): 

584 budget_exhausted = True 

585 msg = "verification skipped: run budget exhausted" 

586 log(msg) 

587 all_warnings.append(msg) 

588 else: 

589 verify_result, verdicts, verify_warnings = _verify( 

590 chair_name, 

591 usable, 

592 all_findings, 

593 diff, 

594 context, 

595 budget, 

596 retries, 

597 log, 

598 template=tmpl["verify"], 

599 ) 

600 all_warnings.extend(verify_warnings) 

601 _apply_verdicts(groups, verdicts) 

602 if verify_result is not None: 

603 emit("verify", verify_result) 

604 

605 # Local-only demotion (issue #442) runs AFTER verification, never before: 

606 # _reject_targets' member-tier guard (orchestrator._reject_targets) assumes 

607 # group.severity == max(member severities) to decide whether a rejecting 

608 # verdict may suppress the whole group. Demoting group.severity earlier 

609 # would desync it from that invariant and could let a verdict aimed at a 

610 # minor local-only duplicate collateral-reject a genuinely critical, 

611 # never-verified co-located finding merged into the same group. 

612 if config.demote_local_only: 

613 vendor_by_reviewer = {a.name: a.vendor for a in config.agents} 

614 demote_local_only_groups(groups, vendor_by_reviewer) 

615 

616 # Synthesis: the chair consolidates. When the resolved chair is ALSO a 

617 # round-1 reviewer, feed it an anonymized view of the reviews (#38 guardrail) 

618 # so it cannot preferentially weight its own findings; the report still 

619 # attributes by real name because it renders the real outcome data, not this 

620 # synthesis prompt. 

621 synthesis: AgentResult | None = None 

622 if budget.expired(): 

623 budget_exhausted = True 

624 msg = "synthesis skipped: run budget exhausted" 

625 log(msg) 

626 if msg not in all_warnings: 626 ↛ 648line 626 didn't jump to line 648 because the condition on line 626 was always true

627 all_warnings.append(msg) 

628 else: 

629 chair_is_reviewer = chair_name in reviewer_names 

630 anonymize_synthesis = config.anonymize_debate and chair_is_reviewer 

631 synthesis = _synthesize( 

632 chair_name, 

633 usable, 

634 reviews, 

635 debate, 

636 diff, 

637 budget, 

638 retries, 

639 log, 

640 verdicts=verdicts, 

641 anonymize_reviews=anonymize_synthesis, 

642 rng=run_rng, 

643 template=tmpl["synthesis"], 

644 ) 

645 if synthesis is not None: 

646 emit("synthesis", synthesis) 

647 

648 return JuryOutcome( 

649 reviews=reviews, 

650 debate=debate, 

651 synthesis=synthesis, 

652 chair=chair_name, 

653 findings=all_findings, 

654 warnings=all_warnings, 

655 groups=groups, 

656 verify=verify_result, 

657 verdicts=verdicts, 

658 context_mode=context_mode, 

659 redact_secrets=redact_on, 

660 redaction_count=redaction_count, 

661 injection_hits=injection_hits, 

662 skipped=skipped, 

663 budget_exhausted=budget_exhausted, 

664 rounds_executed=rounds_executed, 

665 stop_reason=stop_reason, 

666 ) 

667 

668 

669def resolve_chair( 

670 config: JuryConfig, 

671 usable: list[str], 

672 reviewers: list[str], 

673 rng: random.Random, 

674) -> str: 

675 """Resolve the chair for a run as a PURE function of its inputs (#38). 

676 

677 Precedence: 

678 1. ``chair = "rotate"`` — pick deterministically from the usable agents 

679 using the shared run ``rng``. Same seed -> same chair; different seeds 

680 may differ. Falls back to the first usable agent when none are usable. 

681 2. An explicit ``config.chair`` that names a usable agent — honoured as-is 

682 (an operator-chosen chair always wins). 

683 3. ``prefer_non_reviewer_chair`` — when set and a usable agent that was NOT 

684 a successful round-1 reviewer exists, prefer the first such agent 

685 (neutral chair). This only applies when the configured chair is not 

686 itself a usable agent. 

687 4. Fallback to the first usable agent (legacy behaviour). 

688 

689 Keeping this pure (no Adapter objects, no I/O) makes it directly 

690 unit-testable and guarantees ``_verify`` and ``_synthesize`` agree because 

691 the caller resolves it ONCE and threads the result through both. 

692 """ 

693 if not usable: 

694 return config.chair 

695 names = set(usable) 

696 

697 if config.chair == "rotate": 

698 # Deterministic rotation: sort for a stable candidate order independent 

699 # of dict/thread ordering, then index with the shared run RNG. Sorting 

700 # the candidate list (not iterating the set) makes the pick a pure 

701 # function of (seed, usable-name set): same seed + same agents -> same 

702 # chair, regardless of RNG-consumption order elsewhere. 

703 candidates = sorted(names) 

704 return candidates[rng.randrange(len(candidates))] 

705 

706 if config.chair in names: 

707 return config.chair 

708 

709 if config.prefer_non_reviewer_chair: 

710 reviewer_set = set(reviewers) 

711 non_reviewers = [n for n in usable if n not in reviewer_set] 

712 if non_reviewers: 

713 return non_reviewers[0] 

714 

715 return usable[0] 

716 

717 

718def _format_findings_for_verify(findings: list[Finding]) -> str: 

719 """Render candidate findings for the chair's verification prompt. 

720 

721 Reviewer identity is omitted (#250) so the chair can't favour its own 

722 findings while judging them — parity with the #37/#38 anonymization. 

723 Verdicts match back by file/line/claim, so dropping it is safe. 

724 """ 

725 if not findings: 

726 return "_(no candidate findings)_" 

727 lines = [] 

728 for f in findings: 

729 loc = f.file or "?" 

730 if f.line is not None: 

731 loc = f"{loc}:{f.line}" 

732 lines.append(f"- [{f.severity}] {loc}{f.claim}") 

733 return "\n".join(lines) 

734 

735 

736def _format_verdicts(verdicts: list[Verdict]) -> str: 

737 if not verdicts: 

738 return "_(no verification verdicts)_" 

739 lines = [] 

740 for v in verdicts: 

741 loc = v.file or "?" 

742 if v.line is not None: 

743 loc = f"{loc}:{v.line}" 

744 lines.append(f"- [{v.status}] {loc}{v.claim}: {v.reasoning}") 

745 return "\n".join(lines) 

746 

747 

748def _verify( 

749 chair_name, 

750 usable, 

751 findings, 

752 diff, 

753 context, 

754 budget, 

755 retries, 

756 log, 

757 template=prompts.VERIFY, 

758) -> tuple[AgentResult | None, list[Verdict], list[str]]: 

759 chair = next((a for a in usable if a.name == chair_name), None) 

760 if chair is None: 

761 return None, [], [] 

762 log(f"verification: chair '{chair_name}' judging {len(findings)} candidate findings") 

763 prompt = template.format( 

764 diff=prompts.neutralize_sentinels(diff), 

765 findings=prompts.neutralize_sentinels(_format_findings_for_verify(findings)), 

766 context=prompts.neutralize_sentinels(context or "_(none)_"), 

767 notice=prompts._UNTRUSTED_NOTICE, 

768 ) 

769 result = _run_with_retry(chair, prompt, "verify", budget, retries, log) 

770 if not result.ok: 

771 return result, [], [f"verification failed: {result.error}"] 

772 verdicts, warnings = parse_verdicts(result.output, chair_name) 

773 return result, verdicts, warnings 

774 

775 

776def _verdict_matches_group(verdict: Verdict, group: FindingGroup) -> bool: 

777 from .consensus import _normalize_claim, _normalize_path 

778 

779 rep = group.representative 

780 # Case-EXACT path match (fold_case=False): on a case-sensitive filesystem 

781 # ``Config.py`` != ``config.py``, so a verdict must not reject a finding it 

782 # only case-collapses onto (audit 2026-06-13 r6/M). 

783 if _normalize_path(verdict.file, fold_case=False) != _normalize_path(rep.file, fold_case=False): 

784 return False 

785 if verdict.line is not None and rep.line is not None and abs(verdict.line - rep.line) > 3: 

786 return False 

787 v_claim = _normalize_claim(verdict.claim) 

788 r_claim = _normalize_claim(rep.claim) 

789 if not v_claim: 

790 # An empty verdict claim is allowed to match the finding *at this 

791 # location* (the verifier may omit the claim and refer to it by 

792 # position). But a verdict with NEITHER a claim NOR a line has no 

793 # location precision at all: it would otherwise match — and, when 

794 # ``unsupported``, REJECT — every finding group in the file, including 

795 # unrelated criticals, flipping the CI gate from FAIL to PASS. Such a 

796 # claim-less, line-less verdict is a file-wide wildcard and must not 

797 # match (security audit 2026-06-13 r6/M). Require a concrete line that 

798 # actually pins the finding before honoring an empty-claim match. 

799 return verdict.line is not None and rep.line is not None 

800 if v_claim == r_claim: 

801 return True 

802 v_tokens, r_tokens = set(v_claim.split()), set(r_claim.split()) 

803 if not v_tokens or not r_tokens: 

804 return False 

805 inter = len(v_tokens & r_tokens) 

806 union = len(v_tokens) + len(r_tokens) - inter 

807 return (inter / union if union else 0.0) >= 0.5 

808 

809 

810def _claim_sim(a_claim: str, b_claim: str) -> float: 

811 """Token-set similarity between two claims: 1.0 exact, else Jaccard, 0.0 if 

812 either side is empty.""" 

813 from .consensus import _normalize_claim 

814 

815 a = _normalize_claim(a_claim) 

816 b = _normalize_claim(b_claim) 

817 if not a or not b: 

818 return 0.0 

819 if a == b: 

820 return 1.0 

821 at, bt = set(a.split()), set(b.split()) 

822 inter = len(at & bt) 

823 union = len(at) + len(bt) - inter 

824 return (inter / union) if union else 0.0 

825 

826 

827# Verdict statuses that move a finding into a non-blocking bucket (suppress it). 

828_REJECTING_STATUSES = frozenset({"unsupported", "needs_human_decision"}) 

829# Apply most-blocking statuses first so a contradictory verdict pair on one 

830# finding is fail-closed: a `verified` (blocking) judgement is recorded before 

831# any `unsupported`/`needs_human_decision` and cannot then be flipped to 

832# non-blocking by verdict array ordering (audit 2026-06-13 r8/M). 

833_STATUS_PRIORITY = {"verified": 0, "needs_human_decision": 1, "unsupported": 2} 

834# Minimum claim similarity for a verdict to be considered "about" a finding at 

835# all. The PRIMARY defence against a verdict dismissing a co-located *distinct* 

836# finding is that a rejection attaches to AT MOST the single best-matching group 

837# (`_best_reject_target`), so a verdict whose claim copies a benign neighbour 

838# routes to that neighbour, not to the co-located critical (audit 2026-06-13 

839# r8/M). The threshold stays moderate so the verifier's legitimate paraphrased 

840# rejections (it drops the reviewer-name prefix etc.) still apply. 

841_REJECT_CLAIM_THRESHOLD = 0.5 

842 

843 

844def _reject_targets(verdict: Verdict, groups: list[FindingGroup]) -> list[FindingGroup]: 

845 """Un-statused groups a rejecting verdict may suppress (fail-closed, r7/r8). 

846 

847 Defences (each closes a distinct collateral-rejection vector found across 

848 audit rounds 6-9): 

849 

850 0. **Line required.** A rejecting verdict must pin a concrete line. A 

851 line-less verdict is too imprecise to safely suppress a finding and would 

852 act as a file-wide-by-claim wildcard (audit r9/M, the claim-ful 

853 counterpart of the round-6 line-less-wildcard fix). 

854 1. **Member-tier guard.** A group may merge findings of different severities 

855 (consensus keeps the max). A verdict is "about" the member whose claim it 

856 best matches; if that member is *less severe* than the group's max, the 

857 verdict is dismissing a lesser co-located finding and must NOT suppress 

858 the (e.g. critical) group. 

859 2. **Best-tier only.** Across candidate groups, suppress only those at the 

860 highest match similarity — a verdict copying a benign neighbour rejects 

861 that neighbour (and its duplicate phrasings, which tie) but not a 

862 separate, less-similar critical group. 

863 3. **Least-severe within a tie.** If the best-similarity tier still spans 

864 severities (an exact `_claim_sim` tie between a critical and a benign 

865 decoy), suppress only the *least*-severe groups — a tie must never drag a 

866 critical down alongside a decoy (audit r9/M). 

867 """ 

868 from .findings import SEVERITY_ORDER 

869 

870 if verdict.line is None: 

871 return [] 

872 scored: list[tuple[float, FindingGroup]] = [] 

873 for group in groups: 

874 if group.status: 

875 continue 

876 if not _verdict_matches_group(verdict, group): 

877 continue 

878 members = getattr(group, "members", None) or [group.representative] 

879 best_sim, best_member = max( 

880 ((_claim_sim(verdict.claim, m.claim), m) for m in members), 

881 key=lambda t: t[0], 

882 ) 

883 if best_sim < _REJECT_CLAIM_THRESHOLD: 

884 continue 

885 # Member-tier guard: refuse if the verdict best-names a member less 

886 # severe than the group's max severity (lower rank = more severe). 

887 if SEVERITY_ORDER.get(best_member.severity, 99) > SEVERITY_ORDER.get(group.severity, 99): 

888 continue 

889 scored.append((best_sim, group)) 

890 if not scored: 

891 return [] 

892 best = max(sim for sim, _ in scored) 

893 tier = [(sim, group) for sim, group in scored if sim >= best] 

894 # Within the top-similarity tier, keep only the least-severe groups. 

895 least_rank = max(SEVERITY_ORDER.get(g.severity, 99) for _, g in tier) 

896 return [g for _, g in tier if SEVERITY_ORDER.get(g.severity, 99) == least_rank] 

897 

898 

899def _apply_verdicts(groups: list[FindingGroup], verdicts: list[Verdict]) -> None: 

900 """Attach verification statuses to consensus groups. 

901 

902 unsupported -> bucket 'rejected'; needs_human_decision -> bucket 'disputed'; 

903 verified -> status recorded, bucket unchanged. 

904 """ 

905 # Stable-sort by blocking priority so contradictions resolve fail-closed. 

906 for verdict in sorted(verdicts, key=lambda v: _STATUS_PRIORITY.get(v.status, 3)): 

907 if verdict.status in _REJECTING_STATUSES: 

908 # Suppress only the best-similarity tier this verdict names — never 

909 # collaterally a co-located, less-similar distinct finding. 

910 bucket = "rejected" if verdict.status == "unsupported" else "disputed" 

911 for target in _reject_targets(verdict, groups): 

912 target.status = verdict.status 

913 target.status_reasoning = verdict.reasoning 

914 target.bucket = bucket 

915 continue 

916 # A verifying (non-suppressing) verdict may attach to every matching 

917 # group: when reviewers phrase the same issue differently it can land in 

918 # more than one group, and all should carry the judgement. 

919 for group in groups: 

920 if group.status: 

921 continue 

922 if _verdict_matches_group(verdict, group): 

923 group.status = verdict.status 

924 group.status_reasoning = verdict.reasoning 

925 

926 

927def _synthesize( 

928 chair_name, 

929 usable, 

930 reviews, 

931 debate, 

932 diff, 

933 budget, 

934 retries, 

935 log, 

936 verdicts=None, 

937 anonymize_reviews=False, 

938 rng=None, 

939 template=prompts.SYNTHESIS, 

940) -> AgentResult | None: 

941 chair = next((a for a in usable if a.name == chair_name), None) 

942 if chair is None: 

943 return None 

944 log(f"synthesis: chair '{chair_name}' consolidating verdict") 

945 if anonymize_reviews: 

946 # Chair self-preference guardrail (#38): present round-1 reviews to the 

947 # chair under anonymous labels (no agent/vendor identity, no stable 

948 # order) so it cannot tell which review is "its own". Uses the shared run 

949 # RNG for deterministic-but-unstable ordering. ``me=None`` keeps ALL 

950 # reviews (we are not excluding a debater here, only stripping identity). 

951 peer_rng = random.Random(rng.random()) if rng is not None else random.Random() 

952 reviews_txt, _label_map = _anonymize_peers(reviews, None, peer_rng) 

953 else: 

954 reviews_txt = ( 

955 "\n\n".join( 

956 f"### {r.agent} ({r.vendor})\n{r.output}" for r in reviews if r.ok and r.output 

957 ) 

958 or "_(no reviews)_" 

959 ) 

960 debate_txt = ( 

961 "\n\n".join(f"### {r.agent}\n{r.output}" for r in debate if r.ok and r.output) 

962 or "_(no debate round)_" 

963 ) 

964 prompt = template.format( 

965 diff=prompts.neutralize_sentinels(diff), 

966 reviews=prompts.neutralize_sentinels(reviews_txt), 

967 debate=prompts.neutralize_sentinels(debate_txt), 

968 notice=prompts._UNTRUSTED_NOTICE, 

969 ) 

970 if verdicts: 

971 # The verdicts quote candidate findings, which transitively quote 

972 # untrusted diff text (issue v1.5.0/M-1: this addendum was the one slot 

973 # the #316/L-1 fix missed). Fence + neutralize it like every other 

974 # peer-output slot so an embedded closing token can't break out. 

975 prompt += ( 

976 "\n\n=== VERIFICATION VERDICTS (may quote UNTRUSTED text) ===\n" 

977 "<<<UNTRUSTED_FINDINGS\n" 

978 + prompts.neutralize_sentinels(_format_verdicts(verdicts)) 

979 + "\nUNTRUSTED_FINDINGS>>>\n" 

980 ) 

981 return _run_with_retry(chair, prompt, "synthesis", budget, retries, log) 

982 

983 

984def _merge_results_by_agent(phase_lists: list[list[AgentResult]]) -> list[AgentResult]: 

985 """Merge per-chunk results for the same agent into one result (issue #31). 

986 

987 Outputs are concatenated under per-chunk headers, durations summed, ``ok`` is 

988 true if the agent succeeded on any chunk, and ``attempts`` keeps the max so a 

989 retried chunk is still visible. Agent order follows first appearance. 

990 """ 

991 order: list[str] = [] 

992 by_agent: dict[str, list[AgentResult]] = {} 

993 for lst in phase_lists: 

994 for r in lst: 

995 if r.agent not in by_agent: 

996 by_agent[r.agent] = [] 

997 order.append(r.agent) 

998 by_agent[r.agent].append(r) 

999 

1000 merged: list[AgentResult] = [] 

1001 for name in order: 

1002 parts = by_agent[name] 

1003 

1004 # bolt: Consolidate multiple metrics (ok, body, total_duration, max_attempts) 

1005 # into a single-pass O(N) explicit loop to bypass multiple generator instantiations 

1006 ok = False 

1007 body_parts = [] 

1008 first_err = None 

1009 total_duration = 0.0 

1010 max_attempts = 0 

1011 

1012 for i, p in enumerate(parts, 1): 

1013 if p.ok: 

1014 ok = True 

1015 if p.output: 

1016 body_parts.append(f"#### chunk {i}\n{p.output}") 

1017 elif first_err is None: 

1018 first_err = p 

1019 

1020 total_duration += p.duration_s 

1021 if p.attempts > max_attempts: 

1022 max_attempts = p.attempts 

1023 

1024 body = "\n\n".join(body_parts) 

1025 

1026 merged.append( 

1027 AgentResult( 

1028 name, 

1029 parts[0].vendor, 

1030 ok, 

1031 body, 

1032 round(total_duration, 3), 

1033 error=None if ok else (first_err.error if first_err else None), 

1034 error_code=None if ok else (first_err.error_code if first_err else None), 

1035 attempts=max_attempts, 

1036 ) 

1037 ) 

1038 return merged 

1039 

1040 

1041def _combine_chair_results(results: list[AgentResult], chair: str) -> AgentResult | None: 

1042 """Combine per-chunk chair results (verify/synthesis) into one labelled result.""" 

1043 ok_parts = [r for r in results if r.ok and r.output] 

1044 if not ok_parts: 

1045 return results[0] if results else None 

1046 vendor = ok_parts[0].vendor 

1047 

1048 # bolt: Consolidate body text concatenation and duration sum into a single-pass O(N) loop 

1049 body_parts = [] 

1050 total_duration = 0.0 

1051 for i, r in enumerate(ok_parts, 1): 

1052 body_parts.append(f"### chunk {i}\n{r.output}") 

1053 total_duration += r.duration_s 

1054 

1055 body = "\n\n".join(body_parts) 

1056 return AgentResult(chair, vendor, True, body, round(total_duration, 3)) 

1057 

1058 

1059def _merge_chunk_outcomes(outcomes: list[JuryOutcome], config: JuryConfig) -> JuryOutcome: 

1060 """Fold per-chunk outcomes (issue #31) into one renderable JuryOutcome. 

1061 

1062 Findings are unioned and re-grouped across all chunks so the consensus view 

1063 is global; verdicts are re-applied to the merged groups. Review/debate/chair 

1064 outputs are merged per agent with chunk labels so the report stays coherent. 

1065 """ 

1066 if len(outcomes) == 1: 

1067 return outcomes[0] 

1068 base = outcomes[0] 

1069 

1070 reviews = _merge_results_by_agent([o.reviews for o in outcomes]) 

1071 debate = ( 

1072 _merge_results_by_agent([o.debate for o in outcomes]) 

1073 if any(o.debate for o in outcomes) 

1074 else [] 

1075 ) 

1076 findings = [f for o in outcomes for f in o.findings] 

1077 groups = group_findings(findings, len(reviews)) 

1078 verdicts = [v for o in outcomes for v in o.verdicts] 

1079 # Scope each chunk's verdicts to that chunk's own findings. A verdict is 

1080 # produced while verifying ONE chunk (whose prompt held only that chunk's 

1081 # findings, but whose attacker-controlled diff text could steer it); after 

1082 # the global merge an unscoped verdict could reject a *different* chunk's 

1083 # structured critical and flip the CI gate (audit 2026-06-13 r7/M). Chunks 

1084 # are file-disjoint, so apply each chunk's verdicts only to groups whose 

1085 # location is one of that chunk's files. 

1086 from .consensus import _normalize_path 

1087 

1088 for o in outcomes: 

1089 chunk_files = {_normalize_path(f.file, fold_case=False) for f in o.findings if f.file} 

1090 chunk_groups = [ 

1091 g 

1092 for g in groups 

1093 if _normalize_path(g.representative.file, fold_case=False) in chunk_files 

1094 ] 

1095 _apply_verdicts(chunk_groups, o.verdicts) 

1096 

1097 # Runs AFTER verdicts are applied — see the matching comment in run_jury for 

1098 # why (the _reject_targets member-tier guard assumes group.severity is the 

1099 # true max member severity; demoting earlier would desync that invariant). 

1100 if config.demote_local_only: 

1101 vendor_by_reviewer = {a.name: a.vendor for a in config.agents} 

1102 demote_local_only_groups(groups, vendor_by_reviewer) 

1103 

1104 warnings = [w for o in outcomes for w in o.warnings] 

1105 

1106 synthesis = _combine_chair_results([o.synthesis for o in outcomes if o.synthesis], base.chair) 

1107 verify = _combine_chair_results([o.verify for o in outcomes if o.verify], base.chair) 

1108 

1109 # bolt: Consolidate collection aggregations (sum, extend, max, any) into a single-pass O(N) explicit loop 

1110 redaction_count = 0 

1111 injection_hits = [] 

1112 budget_exhausted = False 

1113 rounds_executed = 0 

1114 

1115 for o in outcomes: 

1116 redaction_count += o.redaction_count 

1117 injection_hits.extend(o.injection_hits) 

1118 if o.budget_exhausted: 

1119 budget_exhausted = True 

1120 if o.rounds_executed > rounds_executed: 

1121 rounds_executed = o.rounds_executed 

1122 

1123 return JuryOutcome( 

1124 reviews=reviews, 

1125 debate=debate, 

1126 synthesis=synthesis, 

1127 chair=base.chair, 

1128 findings=findings, 

1129 warnings=warnings, 

1130 groups=groups, 

1131 verify=verify, 

1132 verdicts=verdicts, 

1133 context_mode=base.context_mode, 

1134 redact_secrets=base.redact_secrets, 

1135 redaction_count=redaction_count, 

1136 injection_hits=injection_hits, 

1137 skipped=base.skipped, 

1138 budget_exhausted=budget_exhausted, 

1139 rounds_executed=rounds_executed, 

1140 stop_reason=f"chunked review across {len(outcomes)} part(s)", 

1141 ) 

1142 

1143 

1144def review_diff( 

1145 config: JuryConfig, 

1146 diff: str, 

1147 *, 

1148 context: str = "", 

1149 mock: bool = False, 

1150 strict: bool = False, 

1151 seed: int | None = None, 

1152 policy: ReviewPolicy | None = None, 

1153 log=lambda _msg: None, 

1154 on_event=None, 

1155) -> tuple[JuryOutcome, largediff.DiffPlan]: 

1156 """Plan a diff (filter + size + mode) then run the jury (issue #31). 

1157 

1158 The single entry point the CLI uses: it measures and filters the diff, 

1159 reports the size and the selected handling mode, and dispatches: 

1160 

1161 - ``full`` — review the filtered diff in one ``run_jury`` pass; 

1162 - ``chunked`` — review each chunk and merge the outcomes; 

1163 - ``too_large`` — raise ``RuntimeError`` with an actionable message. 

1164 

1165 Returns ``(outcome, plan)`` so the caller can surface the plan. Existing 

1166 callers of :func:`run_jury` are unaffected. 

1167 """ 

1168 dc = config.diff 

1169 plan = largediff.plan_diff( 

1170 diff, 

1171 max_bytes=dc.max_bytes, 

1172 chunk=dc.chunk, 

1173 chunk_max_bytes=dc.chunk_max_bytes, 

1174 exclude_generated=dc.exclude_generated, 

1175 exclude=dc.exclude, 

1176 include=dc.include, 

1177 ) 

1178 log( 

1179 f"diff size: {plan.total_bytes} B total, {plan.kept_bytes} B after filters " 

1180 f"({len(plan.kept)} file(s) kept, {len(plan.excluded)} excluded); " 

1181 f"mode: {plan.mode}" 

1182 ) 

1183 if plan.excluded: 

1184 log("excluded: " + ", ".join(f"{p} [{why}]" for p, why in plan.excluded)) 

1185 log(plan.reason) 

1186 

1187 if plan.mode == largediff.MODE_TOO_LARGE: 

1188 raise RuntimeError(f"diff too large to review: {plan.reason}") 

1189 if not plan.chunks: 

1190 raise RuntimeError( 

1191 "nothing to review after filters — all files were excluded " 

1192 "(check [jury.diff] include/exclude patterns)" 

1193 ) 

1194 

1195 # One shared budget across all chunks so ``total_timeout`` bounds the WHOLE 

1196 # review, not each chunk independently (review finding). ``phase_timeout`` and 

1197 # per-agent timeouts still apply per call via the same budget. 

1198 shared_budget = RunBudget(config.total_timeout, config.phase_timeout) 

1199 

1200 # Redact the shared context ONCE here, before fan-out (#249). The same context 

1201 # is reviewed against every chunk; letting each per-chunk ``run_jury`` redact 

1202 # it would count its secrets once per chunk and ``_merge_chunk_outcomes`` would 

1203 # sum them, inflating ``redaction_count`` (e.g. a 1-secret context over 8 

1204 # chunks reported 8). Pre-redacting makes each chunk's re-redaction a no-op — 

1205 # the ``[REDACTED:…]`` placeholders no longer match — so we add the one-time 

1206 # context count back at the end. Diff/chunk redactions are still counted 

1207 # per chunk and summed, which is correct (each chunk's diff is distinct). 

1208 # `config.context` is the right path: `_from_dict` flattens the `[jury]` 

1209 # table onto JuryConfig, so the `[jury.context]` sub-table is `config.context` 

1210 # (a ContextConfig), NOT `config.jury.context` — there is no `config.jury`. 

1211 # This mirrors how run_jury() reads it. 

1212 ctx_cfg = getattr(config, "context", None) 

1213 ctx_mode = getattr(ctx_cfg, "mode", "diff-only") if ctx_cfg else "diff-only" 

1214 redact_on = getattr(ctx_cfg, "redact_secrets", True) if ctx_cfg else True 

1215 context_redactions = 0 

1216 if redact_on and ctx_mode != "diff-only" and context: 

1217 context, context_redactions = redact(context) 

1218 

1219 def _run(chunk: str) -> JuryOutcome: 

1220 return run_jury( 

1221 config, 

1222 chunk, 

1223 context=context, 

1224 mock=mock, 

1225 strict=strict, 

1226 seed=seed, 

1227 policy=policy, 

1228 log=log, 

1229 budget=shared_budget, 

1230 on_event=on_event, 

1231 ) 

1232 

1233 def _finalize(outcome: JuryOutcome) -> JuryOutcome: 

1234 # Add the one-time context redaction count (per-chunk runs saw an already- 

1235 # redacted context and counted 0 for it). 

1236 if not context_redactions: 

1237 return outcome 

1238 return replace(outcome, redaction_count=outcome.redaction_count + context_redactions) 

1239 

1240 if plan.mode == largediff.MODE_FULL: 

1241 return _finalize(_run(plan.chunks[0])), plan 

1242 

1243 outcomes = [] 

1244 for i, chunk in enumerate(plan.chunks, 1): 

1245 log(f"reviewing chunk {i}/{len(plan.chunks)}") 

1246 outcomes.append(_run(chunk)) 

1247 return _finalize(_merge_chunk_outcomes(outcomes, config)), plan