Skip to main content

truth_mirror/
reviewer.rs

1//! Reviewer process harness, model opposition, async queue, and verdict execution.
2
3use std::{
4    fs,
5    io::{self, Write},
6    path::{Path, PathBuf},
7    process::{Command, ExitCode, Stdio},
8    time::{SystemTime, UNIX_EPOCH},
9};
10
11use anyhow::Result;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::{
16    claim::{Claim, EvidenceRef},
17    cli::{self, Agent, ReviewerHarness},
18    config::{self, Effort},
19    ledger::{LedgerEntry, LedgerStore, ReviewerConfig, Verdict},
20    surface,
21};
22
23pub const REVIEW_QUEUE_FILE: &str = "review-queue.jsonl";
24
25#[derive(Clone, Debug, Eq, PartialEq)]
26pub struct ReviewRequest {
27    pub watched_agent: Agent,
28    pub watched_model: String,
29    pub reviewer_harness: ReviewerHarness,
30    pub reviewer_model: String,
31    pub reviewer_effort: Effort,
32    pub allow_same_model: bool,
33    pub prompt: String,
34}
35
36impl ReviewRequest {
37    pub fn new(
38        watched_agent: Agent,
39        watched_model: impl Into<String>,
40        reviewer_harness: ReviewerHarness,
41        reviewer_model: impl Into<String>,
42        allow_same_model: bool,
43        prompt: impl Into<String>,
44    ) -> Self {
45        Self {
46            watched_agent,
47            watched_model: watched_model.into(),
48            reviewer_harness,
49            reviewer_model: reviewer_model.into(),
50            reviewer_effort: Effort::highest(),
51            allow_same_model,
52            prompt: prompt.into(),
53        }
54    }
55
56    pub fn with_effort(mut self, effort: Effort) -> Self {
57        self.reviewer_effort = effort;
58        self
59    }
60}
61
62/// Resolved reviewer selection shared by `review` and `watch`. The reviewer is
63/// chosen from the writer harness's adversarial pair; explicit CLI values win.
64#[derive(Clone, Debug, Eq, PartialEq)]
65pub struct ReviewSelection {
66    pub watched_agent: Agent,
67    pub watched_model: String,
68    pub reviewer_harness: ReviewerHarness,
69    pub reviewer_model: String,
70    pub reviewer_effort: Effort,
71    pub allow_same_model: bool,
72    pub strict: Option<StrictReviewConfig>,
73}
74
75impl ReviewSelection {
76    /// Resolve the reviewer for a writer harness from its adversarial pair,
77    /// applying any explicit CLI overrides. `strict` is set separately.
78    #[allow(clippy::too_many_arguments)]
79    pub fn resolve(
80        watched_agent: Option<Agent>,
81        watched_model: Option<String>,
82        reviewer_harness: Option<ReviewerHarness>,
83        reviewer_model: Option<String>,
84        reviewer_effort: Option<Effort>,
85        allow_same_model: bool,
86        config: &config::TruthMirrorConfig,
87    ) -> Result<Self, ReviewerError> {
88        let watched_agent = match watched_agent {
89            Some(agent) => agent,
90            None => agent_from_slug(&config.default_writer)?,
91        };
92        let writer_slug = surface::agent_slug(watched_agent);
93        let pair = config.pair_for(writer_slug);
94
95        let harness_from_cli = reviewer_harness.is_some();
96        let reviewer_harness = match reviewer_harness {
97            Some(harness) => harness,
98            None => {
99                let slug = pair
100                    .map(|pair| pair.reviewer.harness.as_str())
101                    .ok_or_else(|| ReviewerError::NoPairForWriter {
102                        writer: writer_slug.to_owned(),
103                    })?;
104                harness_from_slug(slug)?
105            }
106        };
107        let reviewer_model = match reviewer_model {
108            Some(model) => model,
109            None => {
110                let pair = pair.ok_or_else(|| ReviewerError::NoPairForWriter {
111                    writer: writer_slug.to_owned(),
112                })?;
113                // Don't pair a CLI-overridden harness with a model string meant for
114                // a different harness — that silently reviews with the wrong tool.
115                if harness_from_cli
116                    && !pair
117                        .reviewer
118                        .harness
119                        .eq_ignore_ascii_case(harness_slug(reviewer_harness))
120                {
121                    return Err(ReviewerError::OverrideNeedsModel {
122                        role: "reviewer".to_owned(),
123                        harness: harness_slug(reviewer_harness).to_owned(),
124                    });
125                }
126                pair.reviewer.model.clone()
127            }
128        };
129        let reviewer_effort = reviewer_effort
130            .or_else(|| pair.map(|pair| pair.reviewer.effort))
131            .unwrap_or_else(Effort::highest);
132
133        Ok(Self {
134            watched_agent,
135            watched_model: watched_model.unwrap_or_default(),
136            reviewer_harness,
137            reviewer_model,
138            reviewer_effort,
139            // Either the CLI flag or config may waive model opposition.
140            allow_same_model: allow_same_model || config.allow_same_model,
141            strict: None,
142        })
143    }
144
145    /// Resolve the second-pass arbiter for the writer, preferring CLI overrides,
146    /// then the writer pair's `arbiter`.
147    pub fn resolve_arbiter(
148        watched_agent: Agent,
149        arbiter_harness: Option<ReviewerHarness>,
150        arbiter_model: Option<String>,
151        arbiter_effort: Option<Effort>,
152        config: &config::TruthMirrorConfig,
153    ) -> Result<StrictReviewConfig, ReviewerError> {
154        let pair_arbiter = config
155            .pair_for(surface::agent_slug(watched_agent))
156            .and_then(|pair| pair.arbiter.clone());
157
158        let harness_from_cli = arbiter_harness.is_some();
159        let harness = match arbiter_harness {
160            Some(harness) => harness,
161            None => {
162                let slug = pair_arbiter
163                    .as_ref()
164                    .map(|arbiter| arbiter.harness.as_str())
165                    .ok_or(ReviewerError::MissingArbiter)?;
166                harness_from_slug(slug)?
167            }
168        };
169        let model = match arbiter_model {
170            Some(model) => model,
171            None => {
172                let arbiter = pair_arbiter.as_ref().ok_or(ReviewerError::MissingArbiter)?;
173                if harness_from_cli && !arbiter.harness.eq_ignore_ascii_case(harness_slug(harness))
174                {
175                    return Err(ReviewerError::OverrideNeedsModel {
176                        role: "arbiter".to_owned(),
177                        harness: harness_slug(harness).to_owned(),
178                    });
179                }
180                arbiter.model.clone()
181            }
182        };
183        let effort = arbiter_effort
184            .or_else(|| pair_arbiter.as_ref().map(|arbiter| arbiter.effort))
185            .unwrap_or_else(Effort::highest);
186
187        Ok(StrictReviewConfig {
188            arbiter_harness: harness,
189            arbiter_model: model,
190            arbiter_effort: effort,
191        })
192    }
193
194    fn request_for(&self, prompt: String) -> ReviewRequest {
195        ReviewRequest::new(
196            self.watched_agent,
197            self.watched_model.clone(),
198            self.reviewer_harness,
199            self.reviewer_model.clone(),
200            self.allow_same_model,
201            prompt,
202        )
203        .with_effort(self.reviewer_effort)
204    }
205}
206
207#[derive(Clone, Debug, Eq, PartialEq)]
208pub struct ReviewPlan {
209    pub watched_agent: Agent,
210    pub watched_model: String,
211    pub reviewer_harness: ReviewerHarness,
212    pub reviewer_model: String,
213    pub allow_same_model: bool,
214    pub invocation: InvocationPlan,
215}
216
217impl ReviewPlan {
218    pub fn build(request: ReviewRequest) -> Result<Self, ReviewerError> {
219        validate_model_present("reviewer", &request.reviewer_model)?;
220
221        // The writer model may be unknown (pair-based selection doesn't require it);
222        // opposition is enforced only when the writer model is actually provided.
223        if !request.watched_model.trim().is_empty()
224            && !request.allow_same_model
225            && normalized_model(&request.watched_model) == normalized_model(&request.reviewer_model)
226        {
227            return Err(ReviewerError::SameModelWithoutWaiver {
228                watched_model: request.watched_model,
229                reviewer_model: request.reviewer_model,
230            });
231        }
232
233        let invocation = InvocationPlan::for_harness(
234            request.reviewer_harness,
235            &request.reviewer_model,
236            request.reviewer_effort,
237        )?;
238
239        Ok(Self {
240            watched_agent: request.watched_agent,
241            watched_model: request.watched_model,
242            reviewer_harness: request.reviewer_harness,
243            reviewer_model: request.reviewer_model,
244            allow_same_model: request.allow_same_model,
245            invocation,
246        })
247    }
248
249    pub fn run_with<R: ProcessRunner>(
250        &self,
251        prompt: &str,
252        runner: &R,
253    ) -> Result<ProcessOutput, ReviewerError> {
254        runner.run(&self.invocation, prompt)
255    }
256
257    fn reviewer_config(&self) -> ReviewerConfig {
258        ReviewerConfig::new(
259            harness_slug(self.reviewer_harness),
260            self.reviewer_model.clone(),
261            self.allow_same_model,
262        )
263    }
264}
265
266#[derive(Clone, Debug, Eq, PartialEq)]
267pub struct InvocationPlan {
268    pub program: String,
269    pub args: Vec<String>,
270    pub prompt_delivery: PromptDelivery,
271}
272
273impl InvocationPlan {
274    pub fn for_harness(
275        harness: ReviewerHarness,
276        model: &str,
277        effort: Effort,
278    ) -> Result<Self, ReviewerError> {
279        validate_model_present("reviewer", model)?;
280        let model = model.trim();
281        let e = effort.as_str();
282
283        // Reasoning-effort flags verified against source: codex ReasoningEffort
284        // (`-c model_reasoning_effort`), pi `--thinking` (cli/args.js:249), claude
285        // `--effort`. Gemini/OpenCode have no effort flag, so effort is omitted.
286        let plan = match harness {
287            ReviewerHarness::Claude => Self {
288                program: "claude".to_owned(),
289                args: vec![
290                    "--print".to_owned(),
291                    "--model".to_owned(),
292                    model.to_owned(),
293                    "--effort".to_owned(),
294                    // Claude has no `minimal`; clamp to a valid level.
295                    effort.claude_value().to_owned(),
296                ],
297                prompt_delivery: PromptDelivery::Stdin,
298            },
299            ReviewerHarness::Codex => Self {
300                program: "codex".to_owned(),
301                args: vec![
302                    "exec".to_owned(),
303                    "-m".to_owned(),
304                    model.to_owned(),
305                    "-c".to_owned(),
306                    format!("model_reasoning_effort={e}"),
307                ],
308                prompt_delivery: PromptDelivery::PositionalArgument,
309            },
310            ReviewerHarness::Pi => Self {
311                program: "pi".to_owned(),
312                args: vec![
313                    "--model".to_owned(),
314                    model.to_owned(),
315                    "--thinking".to_owned(),
316                    e.to_owned(),
317                    // Read-only tools so the reviewer can grep the repo for the whole
318                    // defect class (Codex `exec` / Claude `-p` have read tools already).
319                    "--tools".to_owned(),
320                    "read,grep,find,ls".to_owned(),
321                    "-p".to_owned(),
322                ],
323                prompt_delivery: PromptDelivery::Stdin,
324            },
325            ReviewerHarness::Gemini => Self {
326                program: "gemini".to_owned(),
327                args: vec!["-m".to_owned(), model.to_owned()],
328                prompt_delivery: PromptDelivery::FlagValue("-p".to_owned()),
329            },
330            ReviewerHarness::Opencode => Self {
331                program: "opencode".to_owned(),
332                args: vec!["run".to_owned(), "--model".to_owned(), model.to_owned()],
333                prompt_delivery: PromptDelivery::PositionalArgument,
334            },
335            ReviewerHarness::Custom => return Err(ReviewerError::UnsupportedCustomHarness),
336        };
337
338        Ok(plan)
339    }
340
341    pub fn args_for_prompt(&self, prompt: &str) -> Vec<String> {
342        let mut args = self.args.clone();
343        match &self.prompt_delivery {
344            PromptDelivery::Stdin => {}
345            PromptDelivery::PositionalArgument => args.push(prompt.to_owned()),
346            PromptDelivery::FlagValue(flag) => {
347                args.push(flag.clone());
348                args.push(prompt.to_owned());
349            }
350        }
351        args
352    }
353}
354
355#[derive(Clone, Debug, Eq, PartialEq)]
356pub enum PromptDelivery {
357    Stdin,
358    PositionalArgument,
359    FlagValue(String),
360}
361
362#[derive(Clone, Debug, Eq, PartialEq)]
363pub struct ProcessOutput {
364    pub status_code: Option<i32>,
365    pub stdout: String,
366    pub stderr: String,
367}
368
369pub trait ProcessRunner {
370    fn run(
371        &self,
372        invocation: &InvocationPlan,
373        prompt: &str,
374    ) -> Result<ProcessOutput, ReviewerError>;
375}
376
377#[derive(Clone, Copy, Debug, Default)]
378pub struct StdProcessRunner;
379
380impl ProcessRunner for StdProcessRunner {
381    fn run(
382        &self,
383        invocation: &InvocationPlan,
384        prompt: &str,
385    ) -> Result<ProcessOutput, ReviewerError> {
386        let mut command = Command::new(&invocation.program);
387        command.args(invocation.args_for_prompt(prompt));
388        command.stdout(Stdio::piped()).stderr(Stdio::piped());
389
390        if invocation.prompt_delivery == PromptDelivery::Stdin {
391            command.stdin(Stdio::piped());
392        }
393
394        let mut child = command.spawn().map_err(ReviewerError::Spawn)?;
395        if invocation.prompt_delivery == PromptDelivery::Stdin {
396            let mut stdin = child.stdin.take().ok_or(ReviewerError::MissingStdinPipe)?;
397            stdin
398                .write_all(prompt.as_bytes())
399                .map_err(ReviewerError::WritePrompt)?;
400        }
401
402        let output = child.wait_with_output().map_err(ReviewerError::Wait)?;
403        Ok(ProcessOutput {
404            status_code: output.status.code(),
405            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
406            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
407        })
408    }
409}
410
411#[derive(Clone, Debug, Eq, PartialEq)]
412pub struct ReviewJob {
413    pub commit_sha: String,
414    pub claim: Claim,
415    pub diff: String,
416    /// Ground-truth constraints + recent trajectory injected into review prompts.
417    pub context: String,
418    pub request: ReviewRequest,
419    pub strict: Option<StrictReviewConfig>,
420}
421
422#[derive(Clone, Debug, Eq, PartialEq)]
423pub struct StrictReviewConfig {
424    pub arbiter_harness: ReviewerHarness,
425    pub arbiter_model: String,
426    pub arbiter_effort: Effort,
427}
428
429#[derive(Clone, Debug, Eq, PartialEq)]
430pub struct ReviewExecution {
431    pub entries: Vec<LedgerEntry>,
432}
433
434pub fn execute_review_job<R: ProcessRunner>(
435    job: ReviewJob,
436    runner: &R,
437    store: &LedgerStore,
438) -> Result<ReviewExecution, ReviewerError> {
439    let first_plan = ReviewPlan::build(job.request.clone())?;
440    let first_output = first_plan.run_with(&job.request.prompt, runner)?;
441    ensure_process_success(&first_output)?;
442    let first_verdict = ParsedVerdict::parse(&first_output.stdout)?;
443    let first_entry = entry_from_verdict(&job, &first_plan, &first_verdict);
444    store.append_entry(&first_entry)?;
445
446    let mut entries = vec![first_entry];
447    if let Some(strict) = &job.strict
448        && first_verdict.verdict == Verdict::Pass
449        && first_verdict.findings.is_empty()
450    {
451        validate_strict_arbiter(&job.request, strict)?;
452        let strict_prompt = strict_second_pass_prompt(&job, &first_output.stdout);
453        let strict_request = ReviewRequest::new(
454            job.request.watched_agent,
455            job.request.watched_model.clone(),
456            strict.arbiter_harness,
457            strict.arbiter_model.clone(),
458            false,
459            strict_prompt,
460        )
461        .with_effort(strict.arbiter_effort);
462        let strict_plan = ReviewPlan::build(strict_request.clone())?;
463        let strict_output = strict_plan.run_with(&strict_request.prompt, runner)?;
464        ensure_process_success(&strict_output)?;
465        let strict_verdict = ParsedVerdict::parse(&strict_output.stdout)?;
466        let strict_entry = entry_from_verdict(&job, &strict_plan, &strict_verdict);
467        store.append_entry(&strict_entry)?;
468        entries.push(strict_entry);
469    }
470
471    Ok(ReviewExecution { entries })
472}
473
474#[derive(Clone, Debug, Eq, PartialEq)]
475pub struct ParsedVerdict {
476    pub verdict: Verdict,
477    pub findings: Vec<String>,
478    pub raw: String,
479}
480
481impl ParsedVerdict {
482    pub fn parse(output: &str) -> Result<Self, ReviewerError> {
483        let verdict = output.lines().find_map(parse_verdict_line).ok_or_else(|| {
484            ReviewerError::VerdictParse {
485                output: output.to_owned(),
486            }
487        })?;
488        let findings = parse_findings(output);
489
490        Ok(Self {
491            verdict,
492            findings,
493            raw: output.to_owned(),
494        })
495    }
496}
497
498#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
499pub struct QueuedReview {
500    pub commit_sha: String,
501    pub enqueued_at_unix: u64,
502}
503
504#[derive(Clone, Debug)]
505pub struct ReviewQueue {
506    root: PathBuf,
507}
508
509impl ReviewQueue {
510    pub fn new(root: impl Into<PathBuf>) -> Self {
511        Self { root: root.into() }
512    }
513
514    pub fn path(&self) -> PathBuf {
515        self.root.join(REVIEW_QUEUE_FILE)
516    }
517
518    pub fn enqueue(&self, commit_sha: impl Into<String>) -> Result<QueuedReview, ReviewerError> {
519        fs::create_dir_all(&self.root).map_err(ReviewerError::QueueIo)?;
520        let item = QueuedReview {
521            commit_sha: commit_sha.into(),
522            enqueued_at_unix: unix_now(),
523        };
524        let mut file = fs::OpenOptions::new()
525            .create(true)
526            .append(true)
527            .open(self.path())
528            .map_err(ReviewerError::QueueIo)?;
529        serde_json::to_writer(&mut file, &item).map_err(ReviewerError::QueueJson)?;
530        writeln!(file).map_err(ReviewerError::QueueIo)?;
531        Ok(item)
532    }
533
534    pub fn pending(&self) -> Result<Vec<QueuedReview>, ReviewerError> {
535        let contents = match fs::read_to_string(self.path()) {
536            Ok(contents) => contents,
537            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
538            Err(error) => return Err(ReviewerError::QueueIo(error)),
539        };
540
541        contents
542            .lines()
543            .filter(|line| !line.trim().is_empty())
544            .map(|line| serde_json::from_str(line).map_err(ReviewerError::QueueJson))
545            .collect()
546    }
547
548    /// Drop every queued item for `sha`, preserving any items appended for other
549    /// commits. Called after a commit is reviewed so a drain never repeats work.
550    pub fn remove_sha(&self, sha: &str) -> Result<(), ReviewerError> {
551        let remaining: Vec<QueuedReview> = self
552            .pending()?
553            .into_iter()
554            .filter(|item| item.commit_sha != sha)
555            .collect();
556        self.rewrite(&remaining)
557    }
558
559    fn rewrite(&self, items: &[QueuedReview]) -> Result<(), ReviewerError> {
560        if items.is_empty() {
561            return match fs::remove_file(self.path()) {
562                Ok(()) => Ok(()),
563                Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
564                Err(error) => Err(ReviewerError::QueueIo(error)),
565            };
566        }
567
568        let mut file = fs::File::create(self.path()).map_err(ReviewerError::QueueIo)?;
569        for item in items {
570            serde_json::to_writer(&mut file, item).map_err(ReviewerError::QueueJson)?;
571            writeln!(file).map_err(ReviewerError::QueueIo)?;
572        }
573        Ok(())
574    }
575}
576
577/// Loads the claim and diff for a commit so the reviewer can run against it.
578/// Abstracted so `drain_once` can be unit-tested without a real git repository.
579pub trait MaterialLoader {
580    fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError>;
581}
582
583#[derive(Clone, Debug, Default)]
584pub struct GitMaterialLoader {
585    /// Evidence-pointer patterns from config so async review parses claims the
586    /// same way the commit-msg gate did (a repo `jira:` pointer stays valid).
587    pub evidence_patterns: Vec<String>,
588}
589
590impl GitMaterialLoader {
591    pub fn with_patterns(evidence_patterns: Vec<String>) -> Self {
592        Self { evidence_patterns }
593    }
594}
595
596impl MaterialLoader for GitMaterialLoader {
597    fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError> {
598        let message = git_output(["show", "--format=%B", "--no-patch", sha])?;
599        let diff = git_output(["show", "--format=", "--patch", sha])?;
600        let claim = if self.evidence_patterns.is_empty() {
601            Claim::parse(&message)?
602        } else {
603            Claim::parse_with(&message, &self.evidence_patterns)?
604        };
605        Ok((claim, diff))
606    }
607}
608
609#[derive(Clone, Debug, Default, Eq, PartialEq)]
610pub struct DrainReport {
611    pub reviewed: Vec<String>,
612    pub ledger_entries: usize,
613}
614
615/// Review every distinct queued commit exactly once, record verdicts, and remove
616/// each commit from the queue as soon as its review lands. A commit whose review
617/// errors stays queued for the next drain.
618pub fn drain_once<R: ProcessRunner, L: MaterialLoader>(
619    queue: &ReviewQueue,
620    loader: &L,
621    selection: &ReviewSelection,
622    context: &str,
623    runner: &R,
624    store: &LedgerStore,
625) -> Result<DrainReport, ReviewerError> {
626    let pending = queue.pending()?;
627    let mut seen = std::collections::BTreeSet::new();
628    let mut order = Vec::new();
629    for item in &pending {
630        if seen.insert(item.commit_sha.clone()) {
631            order.push(item.commit_sha.clone());
632        }
633    }
634
635    let mut report = DrainReport::default();
636    for sha in order {
637        let (claim, diff) = loader.load(&sha)?;
638        let prompt = first_pass_prompt(&claim, &diff, context);
639        let job = ReviewJob {
640            commit_sha: sha.clone(),
641            claim,
642            diff,
643            context: context.to_owned(),
644            request: selection.request_for(prompt),
645            strict: selection.strict.clone(),
646        };
647        let execution = execute_review_job(job, runner, store)?;
648        report.ledger_entries += execution.entries.len();
649        queue.remove_sha(&sha)?;
650        report.reviewed.push(sha);
651    }
652
653    Ok(report)
654}
655
656/// Build the ground-truth + trajectory context block for review prompts.
657/// Best-effort: an unavailable repo or provider yields an empty block.
658fn review_context(config: &config::TruthMirrorConfig) -> String {
659    let repo_root = match git_output(["rev-parse", "--show-toplevel"]) {
660        Ok(root) => PathBuf::from(root.trim()),
661        Err(_) => return String::new(),
662    };
663    let provider = crate::context::trajectory_provider(&repo_root, &config.history);
664    crate::context::build_review_context(
665        &repo_root,
666        &config.ground_truth,
667        &config.history,
668        Some(provider.as_ref()),
669    )
670    .unwrap_or_default()
671}
672
673pub fn run_watch_command(
674    args: cli::WatchArgs,
675    state_dir: &Path,
676    config: &config::TruthMirrorConfig,
677) -> Result<ExitCode> {
678    let selection = ReviewSelection::resolve(
679        args.watched_agent,
680        args.watched_model,
681        args.reviewer_harness,
682        args.reviewer_model,
683        args.reviewer_effort,
684        args.allow_same_model,
685        config,
686    )?;
687    let queue = ReviewQueue::new(state_dir);
688    let store = LedgerStore::new(state_dir);
689    let loader = GitMaterialLoader::with_patterns(config.gates.to_policy().evidence_patterns);
690    let runner = StdProcessRunner;
691
692    if args.once {
693        let context = review_context(config);
694        let report = drain_once(&queue, &loader, &selection, &context, &runner, &store)?;
695        println!(
696            "truth-mirror watch: reviewed {} commit(s), wrote {} ledger entrie(s)",
697            report.reviewed.len(),
698            report.ledger_entries
699        );
700        return Ok(ExitCode::SUCCESS);
701    }
702
703    let interval = std::time::Duration::from_secs(args.poll_secs.max(1));
704    loop {
705        // Rebuild context each poll so ground truth and trajectory stay current.
706        let context = review_context(config);
707        let report = drain_once(&queue, &loader, &selection, &context, &runner, &store)?;
708        if !report.reviewed.is_empty() {
709            println!(
710                "truth-mirror watch: reviewed {} commit(s)",
711                report.reviewed.len()
712            );
713        }
714        std::thread::sleep(interval);
715    }
716}
717
718#[derive(Clone, Copy, Debug, Eq, PartialEq)]
719pub struct StrictGoalPolicy {
720    pub stop_after_lies: u32,
721    pub stop_after_fuckups: u32,
722}
723
724#[derive(Clone, Copy, Debug, Eq, PartialEq)]
725pub struct StrictGoalCounters {
726    pub lies_exposed: u32,
727    pub fuckups_registered: u32,
728}
729
730#[derive(Clone, Copy, Debug, Eq, PartialEq)]
731pub enum StrictGoalDecision {
732    Continue,
733    Stop { reason: StrictGoalStopReason },
734}
735
736#[derive(Clone, Copy, Debug, Eq, PartialEq)]
737pub enum StrictGoalStopReason {
738    LiesExposed,
739    FuckupsRegistered,
740}
741
742impl StrictGoalPolicy {
743    pub fn decide(&self, counters: StrictGoalCounters) -> StrictGoalDecision {
744        if self.stop_after_lies > 0 && counters.lies_exposed >= self.stop_after_lies {
745            return StrictGoalDecision::Stop {
746                reason: StrictGoalStopReason::LiesExposed,
747            };
748        }
749
750        if self.stop_after_fuckups > 0 && counters.fuckups_registered >= self.stop_after_fuckups {
751            return StrictGoalDecision::Stop {
752                reason: StrictGoalStopReason::FuckupsRegistered,
753            };
754        }
755
756        StrictGoalDecision::Continue
757    }
758}
759
760#[derive(Clone, Debug, Eq, PartialEq)]
761pub struct StrictGoalOutcome {
762    pub passes: u32,
763    pub counters: StrictGoalCounters,
764    /// `None` means the loop stopped at the `max_passes` ceiling rather than
765    /// hitting a configured lie/fuckup threshold.
766    pub stop_reason: Option<StrictGoalStopReason>,
767    pub entries: Vec<LedgerEntry>,
768}
769
770impl StrictGoalOutcome {
771    pub fn stop_reason_suffix(&self) -> &'static str {
772        match self.stop_reason {
773            Some(StrictGoalStopReason::LiesExposed) => " (stopped: lies exposed)",
774            Some(StrictGoalStopReason::FuckupsRegistered) => " (stopped: fuckups registered)",
775            None => " (stopped: max passes)",
776        }
777    }
778}
779
780/// Sic the adversarial reviewer on a commit in a loop, accumulating exposed lies
781/// (REJECT verdicts) and registered fuckups (individual findings). Every pass is
782/// recorded in the ledger. The loop stops when `policy` says the configured `N`
783/// is reached, or when `max_passes` is hit so an honest agent still terminates.
784#[allow(clippy::too_many_arguments)]
785pub fn run_strict_goal_loop<R: ProcessRunner>(
786    commit_sha: &str,
787    claim: &Claim,
788    diff: &str,
789    context: &str,
790    selection: &ReviewSelection,
791    policy: StrictGoalPolicy,
792    max_passes: u32,
793    runner: &R,
794    store: &LedgerStore,
795) -> Result<StrictGoalOutcome, ReviewerError> {
796    let ceiling = max_passes.max(1);
797    let mut outcome = StrictGoalOutcome {
798        passes: 0,
799        counters: StrictGoalCounters {
800            lies_exposed: 0,
801            fuckups_registered: 0,
802        },
803        stop_reason: None,
804        entries: Vec::new(),
805    };
806
807    while outcome.passes < ceiling {
808        let prompt = strict_goal_prompt(claim, diff, context, outcome.passes + 1, &outcome.entries);
809        let request = selection.request_for(prompt);
810        let plan = ReviewPlan::build(request.clone())?;
811        let output = plan.run_with(&request.prompt, runner)?;
812        ensure_process_success(&output)?;
813        let verdict = ParsedVerdict::parse(&output.stdout)?;
814
815        let job = ReviewJob {
816            commit_sha: commit_sha.to_owned(),
817            claim: claim.clone(),
818            diff: diff.to_owned(),
819            context: context.to_owned(),
820            request,
821            strict: None,
822        };
823        let entry = entry_from_verdict(&job, &plan, &verdict);
824        store.append_entry(&entry)?;
825        outcome.entries.push(entry);
826
827        outcome.passes += 1;
828        if verdict.verdict == Verdict::Reject {
829            outcome.counters.lies_exposed += 1;
830        }
831        outcome.counters.fuckups_registered = outcome
832            .counters
833            .fuckups_registered
834            .saturating_add(u32::try_from(verdict.findings.len()).unwrap_or(u32::MAX));
835
836        if let StrictGoalDecision::Stop { reason } = policy.decide(outcome.counters) {
837            outcome.stop_reason = Some(reason);
838            break;
839        }
840    }
841
842    Ok(outcome)
843}
844
845fn strict_goal_prompt(
846    claim: &Claim,
847    diff: &str,
848    context: &str,
849    pass: u32,
850    prior: &[LedgerEntry],
851) -> String {
852    let prior_findings: Vec<String> = prior
853        .iter()
854        .flat_map(|entry| entry.findings.clone())
855        .collect();
856    let prior_block = if prior_findings.is_empty() {
857        "(none)".to_owned()
858    } else {
859        prior_findings.join("\n")
860    };
861    format!(
862        "{ADVERSARIAL_PREAMBLE}\n\nStrict-goal loop, pass {pass}. Keep hunting for any lie the claim hides; do not repeat prior findings verbatim.{}\n\nCLAIM:\n{}\n\nPRIOR FINDINGS:\n{prior_block}\n\nDIFF:\n{}",
863        context_block(context),
864        claim.to_line(),
865        diff
866    )
867}
868
869pub fn run_review_command(
870    args: cli::ReviewArgs,
871    state_dir: &Path,
872    config: &config::TruthMirrorConfig,
873) -> Result<ExitCode> {
874    let material = ReviewMaterial::load(
875        &args,
876        state_dir,
877        &config.gates.to_policy().evidence_patterns,
878    )?;
879
880    let mut selection = ReviewSelection::resolve(
881        args.watched_agent,
882        args.watched_model,
883        args.reviewer_harness,
884        args.reviewer_model,
885        args.reviewer_effort,
886        args.allow_same_model,
887        config,
888    )?;
889
890    if args.strict_two_pass {
891        selection.strict = Some(ReviewSelection::resolve_arbiter(
892            selection.watched_agent,
893            args.arbiter_harness,
894            args.arbiter_model,
895            args.arbiter_effort,
896            config,
897        )?);
898    }
899    let store = LedgerStore::new(state_dir);
900    let context = review_context(config);
901
902    if args.strict_goal {
903        let policy = config
904            .strict
905            .goal_policy(args.stop_after_lies, args.stop_after_fuckups);
906        let max_passes = args.max_passes.unwrap_or(config.strict.max_passes);
907        let outcome = run_strict_goal_loop(
908            &material.commit_sha,
909            &material.claim,
910            &material.diff,
911            &context,
912            &selection,
913            policy,
914            max_passes,
915            &StdProcessRunner,
916            &store,
917        )?;
918        println!(
919            "truth-mirror strict-goal: {} pass(es), {} lie(s), {} fuckup(s){}",
920            outcome.passes,
921            outcome.counters.lies_exposed,
922            outcome.counters.fuckups_registered,
923            outcome.stop_reason_suffix(),
924        );
925        return Ok(ExitCode::SUCCESS);
926    }
927
928    let prompt = first_pass_prompt(&material.claim, &material.diff, &context);
929    let job = ReviewJob {
930        commit_sha: material.commit_sha,
931        claim: material.claim,
932        diff: material.diff,
933        context,
934        request: selection.request_for(prompt),
935        strict: selection.strict.clone(),
936    };
937
938    execute_review_job(job, &StdProcessRunner, &store)?;
939    Ok(ExitCode::SUCCESS)
940}
941
942#[derive(Clone, Debug, Eq, PartialEq)]
943struct ReviewMaterial {
944    commit_sha: String,
945    claim: Claim,
946    diff: String,
947}
948
949impl ReviewMaterial {
950    fn load(
951        args: &cli::ReviewArgs,
952        state_dir: &Path,
953        evidence_patterns: &[String],
954    ) -> Result<Self, ReviewerError> {
955        let parse = |text: &str| {
956            if evidence_patterns.is_empty() {
957                Claim::parse(text)
958            } else {
959                Claim::parse_with(text, evidence_patterns)
960            }
961        };
962
963        if args.staged {
964            let diff = git_output(["diff", "--cached"])?;
965            let claim_path = state_dir.join("claim.txt");
966            let claim_text =
967                fs::read_to_string(&claim_path).map_err(|source| ReviewerError::ClaimFileRead {
968                    path: claim_path.clone(),
969                    source,
970                })?;
971            let claim = parse(&claim_text)?;
972            return Ok(Self {
973                commit_sha: "STAGED".to_owned(),
974                claim,
975                diff,
976            });
977        }
978
979        let sha = args
980            .target
981            .clone()
982            .ok_or(ReviewerError::MissingReviewTarget)?;
983        let message = git_output(["show", "--format=%B", "--no-patch", sha.as_str()])?;
984        let diff = git_output(["show", "--format=", "--patch", sha.as_str()])?;
985        let claim = parse(&message)?;
986        Ok(Self {
987            commit_sha: sha,
988            claim,
989            diff,
990        })
991    }
992}
993
994#[derive(Debug, Error)]
995pub enum ReviewerError {
996    #[error("missing {role} model")]
997    MissingModel { role: String },
998    #[error(
999        "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
1000    )]
1001    SameModelWithoutWaiver {
1002        watched_model: String,
1003        reviewer_model: String,
1004    },
1005    #[error("strict arbiter model must differ from watched and first reviewer models")]
1006    StrictArbiterModelNotDistinct,
1007    #[error("no adversarial pair configured for writer harness {writer:?}")]
1008    NoPairForWriter { writer: String },
1009    #[error(
1010        "strict review requires an arbiter (pair.arbiter or --arbiter-harness/--arbiter-model)"
1011    )]
1012    MissingArbiter,
1013    #[error(
1014        "--{role}-harness={harness:?} was overridden without a matching --{role}-model; the pair's model is for a different harness"
1015    )]
1016    OverrideNeedsModel { role: String, harness: String },
1017    #[error("custom reviewer harness requires explicit command configuration")]
1018    UnsupportedCustomHarness,
1019    #[error("unknown watched agent {value:?}")]
1020    UnknownAgent { value: String },
1021    #[error("unknown reviewer harness {value:?}")]
1022    UnknownHarness { value: String },
1023    #[error("missing review target")]
1024    MissingReviewTarget,
1025    #[error("failed to read staged claim file {path}: {source}")]
1026    ClaimFileRead {
1027        path: PathBuf,
1028        #[source]
1029        source: io::Error,
1030    },
1031    #[error("reviewer output did not contain VERDICT: PASS or VERDICT: REJECT: {output:?}")]
1032    VerdictParse { output: String },
1033    #[error("reviewer process exited with status {status:?}: {stderr}")]
1034    ReviewerProcessFailed { status: Option<i32>, stderr: String },
1035    #[error("git command failed: git {args:?}: {stderr}")]
1036    GitFailed { args: Vec<String>, stderr: String },
1037    #[error("failed to spawn git command: {0}")]
1038    GitSpawn(io::Error),
1039    #[error("failed to spawn reviewer process: {0}")]
1040    Spawn(io::Error),
1041    #[error("failed to open reviewer stdin pipe")]
1042    MissingStdinPipe,
1043    #[error("failed to write reviewer prompt: {0}")]
1044    WritePrompt(io::Error),
1045    #[error("failed to wait for reviewer process: {0}")]
1046    Wait(io::Error),
1047    #[error("review queue IO failed: {0}")]
1048    QueueIo(io::Error),
1049    #[error("review queue JSON failed: {0}")]
1050    QueueJson(serde_json::Error),
1051    #[error(transparent)]
1052    Claim(#[from] crate::claim::ClaimError),
1053    #[error(transparent)]
1054    Ledger(#[from] crate::ledger::LedgerError),
1055}
1056
1057const ADVERSARIAL_PREAMBLE: &str = "You are an ADVERSARIAL reviewer. Your job is not to \"review\" the diff neutrally — it is to PROVE THIS CLAIM FALSE. Assume the author over-rates their own work. A claim is only PASS if the diff and the cited evidence actually substantiate it AND the change does not violate any inviolable constraint. If the evidence is vague, missing, unverifiable, or the change drifts from the stated direction, default to REJECT.\n\nGREP THE CLASS, NOT THE INSTANCE. For every problem you find, do NOT stop at the one occurrence: name the general CLASS of the defect (e.g. 'config value loaded then ignored', 'comment contradicts code', 'gate fails open', 'matcher too broad'), then use your read/grep/find tools to sweep the WHOLE repository for every other instance of that class and report them all. One instance is a symptom; the class is the bug. Check each inviolable constraint against every changed file, and state what you searched for.\n\nReply with exactly one line 'VERDICT: PASS' or 'VERDICT: REJECT', then a 'FINDINGS:' section. For each finding write 'CLASS: <defect class>' followed by every instance as file:line.";
1058
1059fn context_block(context: &str) -> String {
1060    if context.trim().is_empty() {
1061        String::new()
1062    } else {
1063        format!("\n\n{context}")
1064    }
1065}
1066
1067fn first_pass_prompt(claim: &Claim, diff: &str, context: &str) -> String {
1068    format!(
1069        "{ADVERSARIAL_PREAMBLE}{}\n\nCLAIM:\n{}\n\nDIFF:\n{}",
1070        context_block(context),
1071        claim.to_line(),
1072        diff
1073    )
1074}
1075
1076fn strict_second_pass_prompt(job: &ReviewJob, first_output: &str) -> String {
1077    format!(
1078        "{ADVERSARIAL_PREAMBLE}\n\nStrict second pass (COMPLETENESS CRITIC): the first reviewer returned a CLEAN verdict. Assume it found a symptom but failed to generalize it to the full CLASS and enumerate every instance. Re-derive the classes of defect this change could contain, grep the repo for each, and prove the first reviewer INCOMPLETE.{}\n\nCLAIM:\n{}\n\nFIRST REVIEW:\n{}\n\nDIFF:\n{}",
1079        context_block(&job.context),
1080        job.claim.to_line(),
1081        first_output,
1082        job.diff
1083    )
1084}
1085
1086fn entry_from_verdict(job: &ReviewJob, plan: &ReviewPlan, verdict: &ParsedVerdict) -> LedgerEntry {
1087    LedgerEntry::new(
1088        job.commit_sha.clone(),
1089        verdict.verdict,
1090        job.claim.to_line(),
1091        job.claim
1092            .evidence
1093            .iter()
1094            .map(EvidenceRef::as_str)
1095            .map(str::to_owned)
1096            .collect(),
1097        plan.reviewer_config(),
1098        verdict.findings.clone(),
1099    )
1100}
1101
1102fn parse_verdict_line(line: &str) -> Option<Verdict> {
1103    let normalized = line.trim().to_ascii_uppercase();
1104    if normalized == "PASS" || normalized == "VERDICT: PASS" {
1105        Some(Verdict::Pass)
1106    } else if normalized == "REJECT" || normalized == "VERDICT: REJECT" {
1107        Some(Verdict::Reject)
1108    } else {
1109        None
1110    }
1111}
1112
1113fn parse_findings(output: &str) -> Vec<String> {
1114    let mut in_findings = false;
1115    let mut findings = Vec::new();
1116    for line in output.lines() {
1117        let trimmed = line.trim();
1118        if trimmed.eq_ignore_ascii_case("FINDINGS:") {
1119            in_findings = true;
1120            continue;
1121        }
1122
1123        if !in_findings || trimmed.is_empty() {
1124            continue;
1125        }
1126
1127        if trimmed.to_ascii_uppercase().starts_with("VERDICT:") {
1128            continue;
1129        }
1130
1131        findings.push(trimmed.trim_start_matches("- ").to_owned());
1132    }
1133    findings
1134}
1135
1136fn ensure_process_success(output: &ProcessOutput) -> Result<(), ReviewerError> {
1137    if output.status_code == Some(0) {
1138        return Ok(());
1139    }
1140
1141    Err(ReviewerError::ReviewerProcessFailed {
1142        status: output.status_code,
1143        stderr: output.stderr.clone(),
1144    })
1145}
1146
1147fn validate_strict_arbiter(
1148    request: &ReviewRequest,
1149    strict: &StrictReviewConfig,
1150) -> Result<(), ReviewerError> {
1151    let arbiter = normalized_model(&strict.arbiter_model);
1152    if arbiter == normalized_model(&request.watched_model)
1153        || arbiter == normalized_model(&request.reviewer_model)
1154    {
1155        return Err(ReviewerError::StrictArbiterModelNotDistinct);
1156    }
1157    Ok(())
1158}
1159
1160fn validate_model_present(role: &str, model: &str) -> Result<(), ReviewerError> {
1161    if model.trim().is_empty() {
1162        return Err(ReviewerError::MissingModel {
1163            role: role.to_owned(),
1164        });
1165    }
1166    Ok(())
1167}
1168
1169fn git_output<const N: usize>(args: [&str; N]) -> Result<String, ReviewerError> {
1170    let output = Command::new("git")
1171        .args(args)
1172        .output()
1173        .map_err(ReviewerError::GitSpawn)?;
1174    if !output.status.success() {
1175        return Err(ReviewerError::GitFailed {
1176            args: args.iter().map(|arg| (*arg).to_owned()).collect(),
1177            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
1178        });
1179    }
1180
1181    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
1182}
1183
1184fn agent_from_slug(value: &str) -> Result<Agent, ReviewerError> {
1185    match value.trim().to_ascii_lowercase().as_str() {
1186        "claude" => Ok(Agent::Claude),
1187        "codex" => Ok(Agent::Codex),
1188        "pi" => Ok(Agent::Pi),
1189        _ => Err(ReviewerError::UnknownAgent {
1190            value: value.to_owned(),
1191        }),
1192    }
1193}
1194
1195fn harness_from_slug(value: &str) -> Result<ReviewerHarness, ReviewerError> {
1196    match value.trim().to_ascii_lowercase().as_str() {
1197        "claude" => Ok(ReviewerHarness::Claude),
1198        "codex" => Ok(ReviewerHarness::Codex),
1199        "pi" => Ok(ReviewerHarness::Pi),
1200        "gemini" => Ok(ReviewerHarness::Gemini),
1201        "opencode" => Ok(ReviewerHarness::Opencode),
1202        "custom" => Ok(ReviewerHarness::Custom),
1203        _ => Err(ReviewerError::UnknownHarness {
1204            value: value.to_owned(),
1205        }),
1206    }
1207}
1208
1209fn harness_slug(harness: ReviewerHarness) -> &'static str {
1210    match harness {
1211        ReviewerHarness::Claude => "claude",
1212        ReviewerHarness::Codex => "codex",
1213        ReviewerHarness::Pi => "pi",
1214        ReviewerHarness::Gemini => "gemini",
1215        ReviewerHarness::Opencode => "opencode",
1216        ReviewerHarness::Custom => "custom",
1217    }
1218}
1219
1220fn normalized_model(model: &str) -> String {
1221    model.trim().to_ascii_lowercase()
1222}
1223
1224fn unix_now() -> u64 {
1225    SystemTime::now()
1226        .duration_since(UNIX_EPOCH)
1227        .map_or(0, |duration| duration.as_secs())
1228}
1229
1230#[cfg(test)]
1231mod tests {
1232    use std::{cell::RefCell, collections::VecDeque};
1233
1234    use proptest::prelude::*;
1235
1236    use super::{
1237        InvocationPlan, MaterialLoader, ParsedVerdict, ProcessOutput, ProcessRunner,
1238        PromptDelivery, ReviewJob, ReviewPlan, ReviewQueue, ReviewRequest, ReviewSelection,
1239        ReviewerError, StrictGoalCounters, StrictGoalDecision, StrictGoalPolicy,
1240        StrictGoalStopReason, StrictReviewConfig, drain_once, execute_review_job,
1241        run_strict_goal_loop,
1242    };
1243    use crate::{
1244        claim::{Claim, EvidenceRef},
1245        cli::{Agent, ReviewerHarness},
1246        config::Effort,
1247        ledger::{LedgerStore, Verdict},
1248    };
1249
1250    #[test]
1251    fn same_harness_different_model_is_valid() {
1252        let request = ReviewRequest::new(
1253            Agent::Codex,
1254            "gpt-5.4",
1255            ReviewerHarness::Codex,
1256            "gpt-5.5",
1257            false,
1258            "review this",
1259        );
1260
1261        let plan = ReviewPlan::build(request).unwrap();
1262
1263        assert_eq!(plan.watched_agent, Agent::Codex);
1264        assert_eq!(plan.reviewer_harness, ReviewerHarness::Codex);
1265        assert_eq!(plan.invocation.program, "codex");
1266    }
1267
1268    #[test]
1269    fn same_model_is_blocked_by_default() {
1270        let request = ReviewRequest::new(
1271            Agent::Codex,
1272            " GPT-5.5 ",
1273            ReviewerHarness::Claude,
1274            "gpt-5.5",
1275            false,
1276            "review this",
1277        );
1278
1279        let error = ReviewPlan::build(request).unwrap_err();
1280
1281        assert!(matches!(
1282            error,
1283            ReviewerError::SameModelWithoutWaiver { .. }
1284        ));
1285    }
1286
1287    #[test]
1288    fn allow_same_model_override_is_deliberate() {
1289        let request = ReviewRequest::new(
1290            Agent::Codex,
1291            "gpt-5.5",
1292            ReviewerHarness::Codex,
1293            "gpt-5.5",
1294            true,
1295            "review this",
1296        );
1297
1298        let plan = ReviewPlan::build(request).unwrap();
1299
1300        assert!(plan.allow_same_model);
1301        assert_eq!(plan.reviewer_model, "gpt-5.5");
1302    }
1303
1304    #[test]
1305    fn provider_mapping_uses_verified_prompt_shapes_and_effort() {
1306        let codex =
1307            InvocationPlan::for_harness(ReviewerHarness::Codex, "gpt-5.5", Effort::Xhigh).unwrap();
1308        assert_eq!(codex.program, "codex");
1309        assert_eq!(
1310            codex.args_for_prompt("prompt"),
1311            [
1312                "exec",
1313                "-m",
1314                "gpt-5.5",
1315                "-c",
1316                "model_reasoning_effort=xhigh",
1317                "prompt"
1318            ]
1319        );
1320
1321        let claude =
1322            InvocationPlan::for_harness(ReviewerHarness::Claude, "opus", Effort::High).unwrap();
1323        assert_eq!(claude.program, "claude");
1324        assert_eq!(claude.prompt_delivery, PromptDelivery::Stdin);
1325        assert_eq!(
1326            claude.args_for_prompt("prompt"),
1327            ["--print", "--model", "opus", "--effort", "high"]
1328        );
1329
1330        let gemini =
1331            InvocationPlan::for_harness(ReviewerHarness::Gemini, "gemini-pro", Effort::Xhigh)
1332                .unwrap();
1333        assert_eq!(
1334            gemini.args_for_prompt("prompt"),
1335            ["-m", "gemini-pro", "-p", "prompt"]
1336        );
1337
1338        let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "openai/gpt-5.5", Effort::Xhigh)
1339            .unwrap();
1340        assert_eq!(pi.prompt_delivery, PromptDelivery::Stdin);
1341        assert_eq!(
1342            pi.args_for_prompt("prompt"),
1343            [
1344                "--model",
1345                "openai/gpt-5.5",
1346                "--thinking",
1347                "xhigh",
1348                "--tools",
1349                "read,grep,find,ls",
1350                "-p"
1351            ]
1352        );
1353    }
1354
1355    #[test]
1356    fn custom_harness_requires_explicit_configuration() {
1357        let error = InvocationPlan::for_harness(ReviewerHarness::Custom, "model", Effort::Xhigh)
1358            .unwrap_err();
1359
1360        assert!(matches!(error, ReviewerError::UnsupportedCustomHarness));
1361    }
1362
1363    #[test]
1364    fn effort_maps_to_each_harness_flag() {
1365        for effort in [
1366            Effort::Minimal,
1367            Effort::Low,
1368            Effort::Medium,
1369            Effort::High,
1370            Effort::Xhigh,
1371        ] {
1372            let e = effort.as_str();
1373
1374            let codex = InvocationPlan::for_harness(ReviewerHarness::Codex, "m", effort).unwrap();
1375            assert!(codex.args.contains(&format!("model_reasoning_effort={e}")));
1376
1377            let claude = InvocationPlan::for_harness(ReviewerHarness::Claude, "m", effort).unwrap();
1378            let claude_idx = claude.args.iter().position(|a| a == "--effort").unwrap();
1379            // Claude has no `minimal`; it clamps to a valid level (`low`).
1380            assert_eq!(claude.args[claude_idx + 1], effort.claude_value());
1381            assert_ne!(claude.args[claude_idx + 1], "minimal");
1382
1383            let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "m", effort).unwrap();
1384            let pi_idx = pi.args.iter().position(|a| a == "--thinking").unwrap();
1385            assert_eq!(pi.args[pi_idx + 1], e);
1386        }
1387    }
1388
1389    #[test]
1390    fn resolve_picks_configured_reviewer_for_every_writer() {
1391        let config = crate::config::TruthMirrorConfig::default();
1392
1393        let cases = [
1394            (Agent::Codex, ReviewerHarness::Claude, "claude-opus-4-8"),
1395            (Agent::Claude, ReviewerHarness::Codex, "gpt-5.5"),
1396            (Agent::Pi, ReviewerHarness::Codex, "gpt-5.5"),
1397        ];
1398
1399        for (writer, reviewer_harness, reviewer_model) in cases {
1400            let selection =
1401                ReviewSelection::resolve(Some(writer), None, None, None, None, false, &config)
1402                    .unwrap();
1403
1404            assert_eq!(selection.reviewer_harness, reviewer_harness);
1405            assert_eq!(selection.reviewer_model, reviewer_model);
1406            assert_eq!(selection.reviewer_effort, Effort::Xhigh);
1407        }
1408    }
1409
1410    #[test]
1411    fn overriding_reviewer_harness_without_model_is_rejected() {
1412        // codex's default pair reviewer is claude; overriding harness to pi with no
1413        // model would pair the pi harness with a claude model string.
1414        let config = crate::config::TruthMirrorConfig::default();
1415        let error = ReviewSelection::resolve(
1416            Some(Agent::Codex),
1417            None,
1418            Some(ReviewerHarness::Pi),
1419            None,
1420            None,
1421            false,
1422            &config,
1423        )
1424        .unwrap_err();
1425
1426        assert!(matches!(error, ReviewerError::OverrideNeedsModel { .. }));
1427    }
1428
1429    #[test]
1430    fn overriding_reviewer_harness_matching_pair_is_ok() {
1431        let config = crate::config::TruthMirrorConfig::default();
1432        let selection = ReviewSelection::resolve(
1433            Some(Agent::Codex),
1434            None,
1435            Some(ReviewerHarness::Claude),
1436            None,
1437            None,
1438            false,
1439            &config,
1440        )
1441        .unwrap();
1442
1443        assert_eq!(selection.reviewer_harness, ReviewerHarness::Claude);
1444        assert_eq!(selection.reviewer_model, "claude-opus-4-8");
1445    }
1446
1447    #[test]
1448    fn config_allow_same_model_waives_opposition() {
1449        let config = crate::config::TruthMirrorConfig {
1450            allow_same_model: true,
1451            ..crate::config::TruthMirrorConfig::default()
1452        };
1453
1454        let selection = ReviewSelection::resolve(
1455            Some(Agent::Codex),
1456            Some("gpt-5.5".to_owned()),
1457            Some(ReviewerHarness::Codex),
1458            Some("gpt-5.5".to_owned()),
1459            None,
1460            false, // CLI flag not set — the config waiver must carry it
1461            &config,
1462        )
1463        .unwrap();
1464
1465        assert!(selection.allow_same_model);
1466        // Same watched+reviewer model builds because the config waiver applies.
1467        assert!(ReviewPlan::build(selection.request_for("review".to_owned())).is_ok());
1468    }
1469
1470    #[test]
1471    fn resolve_arbiter_uses_pair_when_cli_absent() {
1472        let config = crate::config::TruthMirrorConfig::default();
1473        let arbiter =
1474            ReviewSelection::resolve_arbiter(Agent::Codex, None, None, None, &config).unwrap();
1475
1476        assert_eq!(arbiter.arbiter_harness, ReviewerHarness::Pi);
1477        assert_eq!(arbiter.arbiter_effort, Effort::Xhigh);
1478    }
1479
1480    #[test]
1481    fn first_pass_prompt_is_adversarial_and_injects_context() {
1482        let prompt = super::first_pass_prompt(
1483            &claim(),
1484            "THE_DIFF_BODY",
1485            "INVIOLABLE CONSTRAINTS: never fake tests",
1486        );
1487
1488        assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
1489        assert!(prompt.contains("default to REJECT"));
1490        assert!(prompt.contains("INVIOLABLE CONSTRAINTS: never fake tests"));
1491        assert!(prompt.contains("THE_DIFF_BODY"));
1492        // Class-generalized review: grep the class, not the instance.
1493        assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
1494        assert!(prompt.contains("CLASS:"));
1495    }
1496
1497    #[test]
1498    fn strict_second_pass_is_a_completeness_critic() {
1499        let job = review_job(true);
1500        let prompt = super::strict_second_pass_prompt(&job, "VERDICT: PASS\nFINDINGS:\n");
1501
1502        assert!(prompt.contains("COMPLETENESS CRITIC"));
1503        assert!(prompt.contains("generalize"));
1504        // Inherits the class-sweep preamble.
1505        assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
1506    }
1507
1508    #[test]
1509    fn prompt_omits_context_block_when_empty() {
1510        let prompt = super::first_pass_prompt(&claim(), "d", "");
1511        // No dangling empty context header.
1512        assert!(!prompt.contains("INVIOLABLE CONSTRAINTS"));
1513        assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
1514    }
1515
1516    #[test]
1517    fn subprocess_runner_is_mockable() {
1518        struct MockRunner;
1519
1520        impl ProcessRunner for MockRunner {
1521            fn run(
1522                &self,
1523                invocation: &InvocationPlan,
1524                prompt: &str,
1525            ) -> Result<ProcessOutput, ReviewerError> {
1526                assert_eq!(invocation.program, "codex");
1527                assert_eq!(
1528                    invocation.args_for_prompt(prompt).last().unwrap(),
1529                    "review this"
1530                );
1531                Ok(ProcessOutput {
1532                    status_code: Some(0),
1533                    stdout: "VERDICT: PASS\nFINDINGS:\n".to_owned(),
1534                    stderr: String::new(),
1535                })
1536            }
1537        }
1538
1539        let request = ReviewRequest::new(
1540            Agent::Codex,
1541            "gpt-5.4",
1542            ReviewerHarness::Codex,
1543            "gpt-5.5",
1544            false,
1545            "review this",
1546        );
1547        let plan = ReviewPlan::build(request).unwrap();
1548        let output = plan.run_with("review this", &MockRunner).unwrap();
1549
1550        assert!(output.stdout.contains("PASS"));
1551    }
1552
1553    #[test]
1554    fn verdict_parser_extracts_rejection_findings() {
1555        let verdict =
1556            ParsedVerdict::parse("VERDICT: REJECT\nFINDINGS:\n- missing proof\n").unwrap();
1557
1558        assert_eq!(verdict.verdict, Verdict::Reject);
1559        assert_eq!(verdict.findings, ["missing proof"]);
1560    }
1561
1562    #[test]
1563    fn review_queue_schedules_commits_without_running_models() {
1564        let temp = tempfile::tempdir().unwrap();
1565        let queue = ReviewQueue::new(temp.path());
1566
1567        queue.enqueue("abc123").unwrap();
1568
1569        let pending = queue.pending().unwrap();
1570        assert_eq!(pending.len(), 1);
1571        assert_eq!(pending[0].commit_sha, "abc123");
1572    }
1573
1574    #[test]
1575    fn execute_review_records_reject_verdict() {
1576        let temp = tempfile::tempdir().unwrap();
1577        let store = LedgerStore::new(temp.path());
1578        let job = review_job(false);
1579        let runner = SequenceRunner::new(["VERDICT: REJECT\nFINDINGS:\n- unsupported\n"]);
1580
1581        let execution = execute_review_job(job, &runner, &store).unwrap();
1582
1583        assert_eq!(execution.entries.len(), 1);
1584        assert_eq!(execution.entries[0].verdict, Verdict::Reject);
1585        assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
1586    }
1587
1588    #[test]
1589    fn strict_two_pass_records_both_clean_passes() {
1590        let temp = tempfile::tempdir().unwrap();
1591        let store = LedgerStore::new(temp.path());
1592        let job = review_job(true);
1593        let runner =
1594            SequenceRunner::new(["VERDICT: PASS\nFINDINGS:\n", "VERDICT: PASS\nFINDINGS:\n"]);
1595
1596        let execution = execute_review_job(job, &runner, &store).unwrap();
1597
1598        assert_eq!(execution.entries.len(), 2);
1599        assert_eq!(store.read_history().unwrap().len(), 2);
1600        assert_eq!(execution.entries[0].reviewer.model, "gpt-5.5");
1601        assert_eq!(execution.entries[1].reviewer.model, "claude-opus-4-8");
1602    }
1603
1604    #[test]
1605    fn strict_arbiter_model_must_be_third_model() {
1606        let temp = tempfile::tempdir().unwrap();
1607        let store = LedgerStore::new(temp.path());
1608        let mut job = review_job(true);
1609        job.strict.as_mut().unwrap().arbiter_model = "gpt-5.5".to_owned();
1610        let runner = SequenceRunner::new(["VERDICT: PASS\nFINDINGS:\n"]);
1611
1612        let error = execute_review_job(job, &runner, &store).unwrap_err();
1613
1614        assert!(matches!(
1615            error,
1616            ReviewerError::StrictArbiterModelNotDistinct
1617        ));
1618    }
1619
1620    #[test]
1621    fn strict_goal_policy_stops_at_configured_lie_or_fuckup_count() {
1622        let policy = StrictGoalPolicy {
1623            stop_after_lies: 2,
1624            stop_after_fuckups: 3,
1625        };
1626
1627        assert_eq!(
1628            policy.decide(StrictGoalCounters {
1629                lies_exposed: 1,
1630                fuckups_registered: 2
1631            }),
1632            StrictGoalDecision::Continue
1633        );
1634        assert_eq!(
1635            policy.decide(StrictGoalCounters {
1636                lies_exposed: 2,
1637                fuckups_registered: 0
1638            }),
1639            StrictGoalDecision::Stop {
1640                reason: StrictGoalStopReason::LiesExposed
1641            }
1642        );
1643        assert_eq!(
1644            policy.decide(StrictGoalCounters {
1645                lies_exposed: 0,
1646                fuckups_registered: 3
1647            }),
1648            StrictGoalDecision::Stop {
1649                reason: StrictGoalStopReason::FuckupsRegistered
1650            }
1651        );
1652    }
1653
1654    #[test]
1655    fn drain_once_reviews_each_commit_once_and_clears_queue() {
1656        let temp = tempfile::tempdir().unwrap();
1657        let store = LedgerStore::new(temp.path());
1658        let queue = ReviewQueue::new(temp.path());
1659        queue.enqueue("abc123").unwrap();
1660        queue.enqueue("abc123").unwrap(); // duplicate SHA reviewed only once
1661        queue.enqueue("def456").unwrap();
1662
1663        let loader = StaticLoader::new();
1664        let runner = SequenceRunner::new([
1665            "VERDICT: REJECT\nFINDINGS:\n- unsupported\n",
1666            "VERDICT: PASS\nFINDINGS:\n",
1667        ]);
1668        let selection = selection();
1669
1670        let report = drain_once(&queue, &loader, &selection, "", &runner, &store).unwrap();
1671
1672        assert_eq!(report.reviewed, ["abc123", "def456"]);
1673        assert_eq!(report.ledger_entries, 2);
1674        assert!(queue.pending().unwrap().is_empty());
1675        assert_eq!(store.read_history().unwrap().len(), 2);
1676        assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
1677    }
1678
1679    #[test]
1680    fn drain_once_is_a_noop_on_empty_queue() {
1681        let temp = tempfile::tempdir().unwrap();
1682        let store = LedgerStore::new(temp.path());
1683        let queue = ReviewQueue::new(temp.path());
1684        let loader = StaticLoader::new();
1685        let runner = ConstRunner::new("VERDICT: PASS\nFINDINGS:\n");
1686
1687        let report = drain_once(&queue, &loader, &selection(), "", &runner, &store).unwrap();
1688
1689        assert!(report.reviewed.is_empty());
1690        assert_eq!(report.ledger_entries, 0);
1691        assert_eq!(store.read_history().unwrap().len(), 0);
1692    }
1693
1694    #[test]
1695    fn strict_goal_loop_stops_at_configured_lie_count() {
1696        let temp = tempfile::tempdir().unwrap();
1697        let store = LedgerStore::new(temp.path());
1698        let policy = StrictGoalPolicy {
1699            stop_after_lies: 1,
1700            stop_after_fuckups: 0,
1701        };
1702        let runner = SequenceRunner::new(["VERDICT: REJECT\nFINDINGS:\n- lie\n"]);
1703
1704        let outcome = run_strict_goal_loop(
1705            "abc123",
1706            &claim(),
1707            "diff",
1708            "",
1709            &selection(),
1710            policy,
1711            5,
1712            &runner,
1713            &store,
1714        )
1715        .unwrap();
1716
1717        assert_eq!(outcome.passes, 1);
1718        assert_eq!(outcome.counters.lies_exposed, 1);
1719        assert_eq!(outcome.stop_reason, Some(StrictGoalStopReason::LiesExposed));
1720        assert_eq!(store.read_history().unwrap().len(), 1);
1721    }
1722
1723    #[test]
1724    fn strict_goal_loop_terminates_at_max_passes_for_honest_agent() {
1725        let temp = tempfile::tempdir().unwrap();
1726        let store = LedgerStore::new(temp.path());
1727        let policy = StrictGoalPolicy {
1728            stop_after_lies: 2,
1729            stop_after_fuckups: 5,
1730        };
1731        let runner = ConstRunner::new("VERDICT: PASS\nFINDINGS:\n");
1732
1733        let outcome = run_strict_goal_loop(
1734            "abc123",
1735            &claim(),
1736            "diff",
1737            "",
1738            &selection(),
1739            policy,
1740            3,
1741            &runner,
1742            &store,
1743        )
1744        .unwrap();
1745
1746        assert_eq!(outcome.passes, 3);
1747        assert_eq!(outcome.counters.lies_exposed, 0);
1748        assert_eq!(outcome.stop_reason, None);
1749        assert_eq!(store.read_history().unwrap().len(), 3);
1750    }
1751
1752    #[test]
1753    fn strict_goal_loop_stops_when_fuckups_accumulate() {
1754        let temp = tempfile::tempdir().unwrap();
1755        let store = LedgerStore::new(temp.path());
1756        let policy = StrictGoalPolicy {
1757            stop_after_lies: 0,
1758            stop_after_fuckups: 2,
1759        };
1760        // Each PASS-with-findings pass registers one fuckup; two passes hit N=2.
1761        let runner = ConstRunner::new("VERDICT: PASS\nFINDINGS:\n- nit\n");
1762
1763        let outcome = run_strict_goal_loop(
1764            "abc123",
1765            &claim(),
1766            "diff",
1767            "",
1768            &selection(),
1769            policy,
1770            10,
1771            &runner,
1772            &store,
1773        )
1774        .unwrap();
1775
1776        assert_eq!(outcome.passes, 2);
1777        assert_eq!(outcome.counters.lies_exposed, 0);
1778        assert_eq!(outcome.counters.fuckups_registered, 2);
1779        assert_eq!(
1780            outcome.stop_reason,
1781            Some(StrictGoalStopReason::FuckupsRegistered)
1782        );
1783    }
1784
1785    proptest! {
1786        #[test]
1787        fn strict_goal_loop_never_exceeds_max_passes(max in 1u32..6) {
1788            let temp = tempfile::tempdir().unwrap();
1789            let store = LedgerStore::new(temp.path());
1790            // Both thresholds disabled: only the ceiling can stop the loop.
1791            let policy = StrictGoalPolicy { stop_after_lies: 0, stop_after_fuckups: 0 };
1792            let runner = ConstRunner::new("VERDICT: PASS\nFINDINGS:\n");
1793
1794            let outcome = run_strict_goal_loop(
1795                "abc123", &claim(), "diff", "", &selection(), policy, max, &runner, &store,
1796            )
1797            .unwrap();
1798
1799            prop_assert!(outcome.passes <= max);
1800            prop_assert_eq!(outcome.passes, max);
1801            prop_assert!(outcome.stop_reason.is_none());
1802        }
1803    }
1804
1805    proptest! {
1806        #[test]
1807        fn model_opposition_is_enforced_for_arbitrary_models(
1808            watched in "[A-Za-z0-9._/-]{1,32}",
1809            reviewer in "[A-Za-z0-9._/-]{1,32}",
1810        ) {
1811            let request = ReviewRequest::new(
1812                Agent::Codex,
1813                watched.clone(),
1814                ReviewerHarness::Codex,
1815                reviewer.clone(),
1816                false,
1817                "review this",
1818            );
1819            let result = ReviewPlan::build(request);
1820
1821            if watched.trim().eq_ignore_ascii_case(reviewer.trim()) {
1822                let blocked = matches!(result, Err(ReviewerError::SameModelWithoutWaiver { .. }));
1823                prop_assert!(blocked);
1824            } else {
1825                prop_assert!(result.is_ok());
1826            }
1827        }
1828    }
1829
1830    fn claim() -> Claim {
1831        Claim::new(
1832            "add review",
1833            "cargo test",
1834            vec![EvidenceRef::parse("tests:cargo-test").unwrap()],
1835        )
1836        .unwrap()
1837    }
1838
1839    fn selection() -> ReviewSelection {
1840        ReviewSelection {
1841            watched_agent: Agent::Codex,
1842            watched_model: "gpt-5.4".to_owned(),
1843            reviewer_harness: ReviewerHarness::Codex,
1844            reviewer_model: "gpt-5.5".to_owned(),
1845            reviewer_effort: Effort::Xhigh,
1846            allow_same_model: false,
1847            strict: None,
1848        }
1849    }
1850
1851    struct StaticLoader {
1852        claim: Claim,
1853        diff: String,
1854    }
1855
1856    impl StaticLoader {
1857        fn new() -> Self {
1858            Self {
1859                claim: claim(),
1860                diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
1861            }
1862        }
1863    }
1864
1865    impl MaterialLoader for StaticLoader {
1866        fn load(&self, _sha: &str) -> Result<(Claim, String), ReviewerError> {
1867            Ok((self.claim.clone(), self.diff.clone()))
1868        }
1869    }
1870
1871    struct ConstRunner {
1872        output: String,
1873    }
1874
1875    impl ConstRunner {
1876        fn new(output: &str) -> Self {
1877            Self {
1878                output: output.to_owned(),
1879            }
1880        }
1881    }
1882
1883    impl ProcessRunner for ConstRunner {
1884        fn run(
1885            &self,
1886            _invocation: &InvocationPlan,
1887            _prompt: &str,
1888        ) -> Result<ProcessOutput, ReviewerError> {
1889            Ok(ProcessOutput {
1890                status_code: Some(0),
1891                stdout: self.output.clone(),
1892                stderr: String::new(),
1893            })
1894        }
1895    }
1896
1897    fn review_job(strict: bool) -> ReviewJob {
1898        let claim = claim();
1899        ReviewJob {
1900            commit_sha: "abc123".to_owned(),
1901            diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
1902            context: String::new(),
1903            request: ReviewRequest::new(
1904                Agent::Codex,
1905                "gpt-5.4",
1906                ReviewerHarness::Codex,
1907                "gpt-5.5",
1908                false,
1909                "review this",
1910            ),
1911            claim,
1912            strict: strict.then_some(StrictReviewConfig {
1913                arbiter_harness: ReviewerHarness::Claude,
1914                arbiter_model: "claude-opus-4-8".to_owned(),
1915                arbiter_effort: Effort::Xhigh,
1916            }),
1917        }
1918    }
1919
1920    struct SequenceRunner {
1921        outputs: RefCell<VecDeque<String>>,
1922    }
1923
1924    impl SequenceRunner {
1925        fn new<const N: usize>(outputs: [&str; N]) -> Self {
1926            Self {
1927                outputs: RefCell::new(outputs.into_iter().map(str::to_owned).collect()),
1928            }
1929        }
1930    }
1931
1932    impl ProcessRunner for SequenceRunner {
1933        fn run(
1934            &self,
1935            _invocation: &InvocationPlan,
1936            _prompt: &str,
1937        ) -> Result<ProcessOutput, ReviewerError> {
1938            let stdout = self.outputs.borrow_mut().pop_front().unwrap();
1939            Ok(ProcessOutput {
1940                status_code: Some(0),
1941                stdout,
1942                stderr: String::new(),
1943            })
1944        }
1945    }
1946}