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, ReviewScope, ReviewerHarness},
18    config::{self, Effort},
19    ledger::{
20        LedgerEntry, LedgerStore, MemorySkillClassification, ReviewerConfig, StructuredFinding,
21        Verdict,
22    },
23    provenance::{self, EntireCheckpointRef},
24    surface,
25    time::unix_now,
26};
27
28pub const REVIEW_QUEUE_FILE: &str = "review-queue.jsonl";
29pub const REVIEW_RUNS_DIR: &str = "runs";
30const MAX_INLINE_DIFF_FILES: usize = 2;
31const MAX_INLINE_DIFF_BYTES: usize = 256 * 1024;
32const MAX_UNTRACKED_FILE_BYTES: u64 = 16 * 1024;
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct ReviewRequest {
36    pub watched_agent: Agent,
37    pub watched_model: String,
38    pub reviewer_harness: ReviewerHarness,
39    pub reviewer_model: String,
40    pub reviewer_effort: Effort,
41    pub allow_same_model: bool,
42    pub prompt: String,
43}
44
45impl ReviewRequest {
46    pub fn new(
47        watched_agent: Agent,
48        watched_model: impl Into<String>,
49        reviewer_harness: ReviewerHarness,
50        reviewer_model: impl Into<String>,
51        allow_same_model: bool,
52        prompt: impl Into<String>,
53    ) -> Self {
54        Self {
55            watched_agent,
56            watched_model: watched_model.into(),
57            reviewer_harness,
58            reviewer_model: reviewer_model.into(),
59            reviewer_effort: Effort::highest(),
60            allow_same_model,
61            prompt: prompt.into(),
62        }
63    }
64
65    pub fn with_effort(mut self, effort: Effort) -> Self {
66        self.reviewer_effort = effort;
67        self
68    }
69}
70
71/// Resolved reviewer selection shared by `review` and `watch`. The reviewer is
72/// chosen from the writer harness's adversarial pair; explicit CLI values win.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct ReviewSelection {
75    pub watched_agent: Agent,
76    pub watched_model: String,
77    pub reviewer_harness: ReviewerHarness,
78    pub reviewer_model: String,
79    pub reviewer_effort: Effort,
80    pub allow_same_model: bool,
81    pub strict: Option<StrictReviewConfig>,
82}
83
84impl ReviewSelection {
85    /// Resolve the reviewer for a writer harness from its adversarial pair,
86    /// applying any explicit CLI overrides. `strict` is set separately.
87    #[allow(clippy::too_many_arguments)]
88    pub fn resolve(
89        watched_agent: Option<Agent>,
90        watched_model: Option<String>,
91        reviewer_harness: Option<ReviewerHarness>,
92        reviewer_model: Option<String>,
93        reviewer_effort: Option<Effort>,
94        allow_same_model: bool,
95        config: &config::TruthMirrorConfig,
96    ) -> Result<Self, ReviewerError> {
97        let watched_agent = match watched_agent {
98            Some(agent) => agent,
99            None => agent_from_slug(&config.default_writer)?,
100        };
101        let writer_slug = surface::agent_slug(watched_agent);
102        let pair = config.pair_for(writer_slug);
103
104        let harness_from_cli = reviewer_harness.is_some();
105        let reviewer_harness = match reviewer_harness {
106            Some(harness) => harness,
107            None => {
108                let slug = pair
109                    .map(|pair| pair.reviewer.harness.as_str())
110                    .ok_or_else(|| ReviewerError::NoPairForWriter {
111                        writer: writer_slug.to_owned(),
112                    })?;
113                harness_from_slug(slug)?
114            }
115        };
116        let reviewer_model = match reviewer_model {
117            Some(model) => model,
118            None => {
119                let pair = pair.ok_or_else(|| ReviewerError::NoPairForWriter {
120                    writer: writer_slug.to_owned(),
121                })?;
122                // Don't pair a CLI-overridden harness with a model string meant for
123                // a different harness — that silently reviews with the wrong tool.
124                if harness_from_cli
125                    && !pair
126                        .reviewer
127                        .harness
128                        .eq_ignore_ascii_case(harness_slug(reviewer_harness))
129                {
130                    return Err(ReviewerError::OverrideNeedsModel {
131                        role: "reviewer".to_owned(),
132                        harness: harness_slug(reviewer_harness).to_owned(),
133                    });
134                }
135                pair.reviewer.model.clone()
136            }
137        };
138        let reviewer_effort = reviewer_effort
139            .or_else(|| pair.map(|pair| pair.reviewer.effort))
140            .unwrap_or_else(Effort::highest);
141
142        Ok(Self {
143            watched_agent,
144            watched_model: watched_model.unwrap_or_default(),
145            reviewer_harness,
146            reviewer_model,
147            reviewer_effort,
148            // Either the CLI flag or config may waive model opposition.
149            allow_same_model: allow_same_model || config.allow_same_model,
150            strict: None,
151        })
152    }
153
154    /// Resolve the second-pass arbiter for the writer, preferring CLI overrides,
155    /// then the writer pair's `arbiter`.
156    pub fn resolve_arbiter(
157        watched_agent: Agent,
158        arbiter_harness: Option<ReviewerHarness>,
159        arbiter_model: Option<String>,
160        arbiter_effort: Option<Effort>,
161        config: &config::TruthMirrorConfig,
162    ) -> Result<StrictReviewConfig, ReviewerError> {
163        let pair_arbiter = config
164            .pair_for(surface::agent_slug(watched_agent))
165            .and_then(|pair| pair.arbiter.clone());
166
167        let harness_from_cli = arbiter_harness.is_some();
168        let harness = match arbiter_harness {
169            Some(harness) => harness,
170            None => {
171                let slug = pair_arbiter
172                    .as_ref()
173                    .map(|arbiter| arbiter.harness.as_str())
174                    .ok_or(ReviewerError::MissingArbiter)?;
175                harness_from_slug(slug)?
176            }
177        };
178        let model = match arbiter_model {
179            Some(model) => model,
180            None => {
181                let arbiter = pair_arbiter.as_ref().ok_or(ReviewerError::MissingArbiter)?;
182                if harness_from_cli && !arbiter.harness.eq_ignore_ascii_case(harness_slug(harness))
183                {
184                    return Err(ReviewerError::OverrideNeedsModel {
185                        role: "arbiter".to_owned(),
186                        harness: harness_slug(harness).to_owned(),
187                    });
188                }
189                arbiter.model.clone()
190            }
191        };
192        let effort = arbiter_effort
193            .or_else(|| pair_arbiter.as_ref().map(|arbiter| arbiter.effort))
194            .unwrap_or_else(Effort::highest);
195
196        Ok(StrictReviewConfig {
197            arbiter_harness: harness,
198            arbiter_model: model,
199            arbiter_effort: effort,
200        })
201    }
202
203    fn request_for(&self, prompt: String) -> ReviewRequest {
204        ReviewRequest::new(
205            self.watched_agent,
206            self.watched_model.clone(),
207            self.reviewer_harness,
208            self.reviewer_model.clone(),
209            self.allow_same_model,
210            prompt,
211        )
212        .with_effort(self.reviewer_effort)
213    }
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
217pub struct ReviewPlan {
218    pub watched_agent: Agent,
219    pub watched_model: String,
220    pub reviewer_harness: ReviewerHarness,
221    pub reviewer_model: String,
222    pub allow_same_model: bool,
223    pub invocation: InvocationPlan,
224}
225
226impl ReviewPlan {
227    pub fn build(request: ReviewRequest) -> Result<Self, ReviewerError> {
228        validate_model_present("reviewer", &request.reviewer_model)?;
229
230        // The writer model may be unknown (pair-based selection doesn't require it);
231        // opposition is enforced only when the writer model is actually provided.
232        if request.watched_model.trim().is_empty() {
233            if !request.allow_same_model {
234                // The writer model is unknown so we cannot verify model opposition at
235                // runtime — the review will proceed, but warn so this doesn't go silent.
236                eprintln!(
237                    "{} warning: writer model is unknown; model opposition is only pair-based \
238                     for this review. Pass --watched-model or set reviewer.model in config to \
239                     enforce per-model opposition.",
240                    crate::messages::diagnostic_prefix()
241                );
242            }
243        } else if !request.allow_same_model
244            && normalized_model(&request.watched_model) == normalized_model(&request.reviewer_model)
245        {
246            return Err(ReviewerError::SameModelWithoutWaiver {
247                watched_model: request.watched_model,
248                reviewer_model: request.reviewer_model,
249            });
250        }
251
252        let invocation = InvocationPlan::for_harness(
253            request.reviewer_harness,
254            &request.reviewer_model,
255            request.reviewer_effort,
256        )?;
257
258        Ok(Self {
259            watched_agent: request.watched_agent,
260            watched_model: request.watched_model,
261            reviewer_harness: request.reviewer_harness,
262            reviewer_model: request.reviewer_model,
263            allow_same_model: request.allow_same_model,
264            invocation,
265        })
266    }
267
268    pub fn run_with<R: ProcessRunner>(
269        &self,
270        prompt: &str,
271        runner: &R,
272    ) -> Result<ProcessOutput, ReviewerError> {
273        runner.run(&self.invocation, prompt)
274    }
275
276    fn reviewer_config(&self) -> ReviewerConfig {
277        ReviewerConfig::new(
278            harness_slug(self.reviewer_harness),
279            self.reviewer_model.clone(),
280            self.allow_same_model,
281        )
282    }
283}
284
285#[derive(Clone, Debug, Eq, PartialEq)]
286pub struct InvocationPlan {
287    pub program: String,
288    pub args: Vec<String>,
289    pub prompt_delivery: PromptDelivery,
290}
291
292impl InvocationPlan {
293    pub fn for_harness(
294        harness: ReviewerHarness,
295        model: &str,
296        effort: Effort,
297    ) -> Result<Self, ReviewerError> {
298        validate_model_present("reviewer", model)?;
299        let model = model.trim();
300        let e = effort.as_str();
301
302        // Reasoning-effort flags verified against source: codex ReasoningEffort
303        // (`-c model_reasoning_effort`), pi `--thinking` (cli/args.js:249), claude
304        // `--effort`. Gemini/OpenCode have no effort flag, so effort is omitted.
305        let plan = match harness {
306            ReviewerHarness::Claude => Self {
307                program: "claude".to_owned(),
308                args: vec![
309                    "--print".to_owned(),
310                    "--model".to_owned(),
311                    model.to_owned(),
312                    "--effort".to_owned(),
313                    // Claude has no `minimal`; clamp to a valid level.
314                    effort.claude_value().to_owned(),
315                ],
316                prompt_delivery: PromptDelivery::Stdin,
317            },
318            ReviewerHarness::Codex => Self {
319                program: "codex".to_owned(),
320                args: vec![
321                    "exec".to_owned(),
322                    "-m".to_owned(),
323                    model.to_owned(),
324                    "-c".to_owned(),
325                    format!("model_reasoning_effort={e}"),
326                ],
327                prompt_delivery: PromptDelivery::PositionalArgument,
328            },
329            ReviewerHarness::Pi => Self {
330                program: "pi".to_owned(),
331                args: vec![
332                    "--model".to_owned(),
333                    model.to_owned(),
334                    "--thinking".to_owned(),
335                    e.to_owned(),
336                    // Read-only tools so the reviewer can grep the repo for the whole
337                    // defect class (Codex `exec` / Claude `-p` have read tools already).
338                    "--tools".to_owned(),
339                    "read,grep,find,ls".to_owned(),
340                    "-p".to_owned(),
341                ],
342                prompt_delivery: PromptDelivery::Stdin,
343            },
344            ReviewerHarness::Gemini => Self {
345                program: "gemini".to_owned(),
346                args: vec!["-m".to_owned(), model.to_owned()],
347                prompt_delivery: PromptDelivery::FlagValue("-p".to_owned()),
348            },
349            ReviewerHarness::Opencode => Self {
350                program: "opencode".to_owned(),
351                args: vec!["run".to_owned(), "--model".to_owned(), model.to_owned()],
352                prompt_delivery: PromptDelivery::PositionalArgument,
353            },
354            ReviewerHarness::Custom => return Err(ReviewerError::UnsupportedCustomHarness),
355        };
356
357        Ok(plan)
358    }
359
360    pub fn args_for_prompt(&self, prompt: &str) -> Vec<String> {
361        let mut args = self.args.clone();
362        match &self.prompt_delivery {
363            PromptDelivery::Stdin => {}
364            PromptDelivery::PositionalArgument => args.push(prompt.to_owned()),
365            PromptDelivery::FlagValue(flag) => {
366                args.push(flag.clone());
367                args.push(prompt.to_owned());
368            }
369        }
370        args
371    }
372}
373
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub enum PromptDelivery {
376    Stdin,
377    PositionalArgument,
378    FlagValue(String),
379}
380
381#[derive(Clone, Debug, Eq, PartialEq)]
382pub struct ProcessOutput {
383    pub status_code: Option<i32>,
384    pub stdout: String,
385    pub stderr: String,
386}
387
388pub trait ProcessRunner {
389    fn run(
390        &self,
391        invocation: &InvocationPlan,
392        prompt: &str,
393    ) -> Result<ProcessOutput, ReviewerError>;
394}
395
396#[derive(Clone, Copy, Debug, Default)]
397pub struct StdProcessRunner;
398
399impl ProcessRunner for StdProcessRunner {
400    fn run(
401        &self,
402        invocation: &InvocationPlan,
403        prompt: &str,
404    ) -> Result<ProcessOutput, ReviewerError> {
405        let mut command = Command::new(&invocation.program);
406        command.args(invocation.args_for_prompt(prompt));
407        command.stdout(Stdio::piped()).stderr(Stdio::piped());
408
409        if invocation.prompt_delivery == PromptDelivery::Stdin {
410            command.stdin(Stdio::piped());
411        }
412
413        let mut child = command.spawn().map_err(ReviewerError::Spawn)?;
414        if invocation.prompt_delivery == PromptDelivery::Stdin {
415            let mut stdin = child.stdin.take().ok_or(ReviewerError::MissingStdinPipe)?;
416            stdin
417                .write_all(prompt.as_bytes())
418                .map_err(ReviewerError::WritePrompt)?;
419        }
420
421        let output = child.wait_with_output().map_err(ReviewerError::Wait)?;
422        Ok(ProcessOutput {
423            status_code: output.status.code(),
424            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
425            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
426        })
427    }
428}
429
430#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct ReviewJob {
432    pub commit_sha: String,
433    pub claim: Claim,
434    pub diff: String,
435    /// Ground-truth constraints + recent trajectory injected into review prompts.
436    pub context: String,
437    pub request: ReviewRequest,
438    pub strict: Option<StrictReviewConfig>,
439}
440
441#[derive(Clone, Debug, Eq, PartialEq)]
442pub struct StrictReviewConfig {
443    pub arbiter_harness: ReviewerHarness,
444    pub arbiter_model: String,
445    pub arbiter_effort: Effort,
446}
447
448#[derive(Clone, Debug, Eq, PartialEq)]
449pub struct ReviewExecution {
450    pub entries: Vec<LedgerEntry>,
451}
452
453pub fn execute_review_job<R: ProcessRunner>(
454    job: ReviewJob,
455    runner: &R,
456    store: &LedgerStore,
457) -> Result<ReviewExecution, ReviewerError> {
458    let first_plan = ReviewPlan::build(job.request.clone())?;
459    let first_output = first_plan.run_with(&job.request.prompt, runner)?;
460    ensure_process_success(&first_output)?;
461    let first_verdict = ParsedVerdict::parse(&first_output.stdout)?;
462    let first_entry = entry_from_verdict(&job, &first_plan, &first_verdict);
463    store.append_entry(&first_entry)?;
464
465    let mut entries = vec![first_entry];
466    if let Some(strict) = &job.strict
467        && first_verdict.verdict == Verdict::Pass
468        && first_verdict.findings.is_empty()
469    {
470        validate_strict_arbiter(&job.request, strict)?;
471        let strict_prompt = strict_second_pass_prompt(&job, &first_output.stdout);
472        let strict_request = ReviewRequest::new(
473            job.request.watched_agent,
474            job.request.watched_model.clone(),
475            strict.arbiter_harness,
476            strict.arbiter_model.clone(),
477            false,
478            strict_prompt,
479        )
480        .with_effort(strict.arbiter_effort);
481        let strict_plan = ReviewPlan::build(strict_request.clone())?;
482        let strict_output = strict_plan.run_with(&strict_request.prompt, runner)?;
483        ensure_process_success(&strict_output)?;
484        let strict_verdict = ParsedVerdict::parse(&strict_output.stdout)?;
485        let strict_entry = entry_from_verdict(&job, &strict_plan, &strict_verdict);
486        store.append_entry(&strict_entry)?;
487        entries.push(strict_entry);
488    }
489
490    Ok(ReviewExecution { entries })
491}
492
493#[derive(Clone, Debug, Eq, PartialEq)]
494pub struct ParsedVerdict {
495    pub verdict: Verdict,
496    pub summary: String,
497    pub findings: Vec<String>,
498    pub structured_findings: Vec<StructuredFinding>,
499    pub next_steps: Vec<String>,
500    pub memory_skill_classification: MemorySkillClassification,
501    pub raw: String,
502}
503
504impl ParsedVerdict {
505    pub fn parse(output: &str) -> Result<Self, ReviewerError> {
506        let parsed: ReviewerJsonOutput =
507            serde_json::from_str(output.trim()).map_err(|source| ReviewerError::VerdictJson {
508                source,
509                output: output.to_owned(),
510            })?;
511        parsed.validate()?;
512        let findings = parsed
513            .findings
514            .iter()
515            .map(StructuredFinding::display_line)
516            .collect();
517
518        Ok(Self {
519            verdict: parsed.verdict,
520            summary: parsed.summary,
521            findings,
522            structured_findings: parsed.findings,
523            next_steps: parsed.next_steps,
524            memory_skill_classification: parsed.memory_skill,
525            raw: output.to_owned(),
526        })
527    }
528}
529
530#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
531struct ReviewerJsonOutput {
532    verdict: Verdict,
533    summary: String,
534    #[serde(default)]
535    findings: Vec<StructuredFinding>,
536    #[serde(default)]
537    next_steps: Vec<String>,
538    memory_skill: MemorySkillClassification,
539}
540
541impl ReviewerJsonOutput {
542    fn validate(&self) -> Result<(), ReviewerError> {
543        if self.summary.trim().is_empty() {
544            return Err(ReviewerError::VerdictSchema {
545                message: "summary must not be empty".to_owned(),
546            });
547        }
548        self.memory_skill
549            .validate_for_verdict(self.verdict)
550            .map_err(|message| ReviewerError::VerdictSchema { message })?;
551
552        for finding in &self.findings {
553            if finding.title.trim().is_empty() {
554                return Err(ReviewerError::VerdictSchema {
555                    message: "finding title must not be empty".to_owned(),
556                });
557            }
558            if finding.body.trim().is_empty() {
559                return Err(ReviewerError::VerdictSchema {
560                    message: "finding body must not be empty".to_owned(),
561                });
562            }
563            if finding.file.trim().is_empty() {
564                return Err(ReviewerError::VerdictSchema {
565                    message: "finding file must not be empty".to_owned(),
566                });
567            }
568            if finding.line_start == 0 || finding.line_end == 0 {
569                return Err(ReviewerError::VerdictSchema {
570                    message: "finding lines must be one-based".to_owned(),
571                });
572            }
573            if finding.line_end < finding.line_start {
574                return Err(ReviewerError::VerdictSchema {
575                    message: "finding line_end must be greater than or equal to line_start"
576                        .to_owned(),
577                });
578            }
579            if finding.confidence > 100 {
580                return Err(ReviewerError::VerdictSchema {
581                    message: "finding confidence must be between 0 and 100".to_owned(),
582                });
583            }
584            if finding.recommendation.trim().is_empty() {
585                return Err(ReviewerError::VerdictSchema {
586                    message: "finding recommendation must not be empty".to_owned(),
587                });
588            }
589        }
590
591        if self.verdict == Verdict::Pass && !self.findings.is_empty() {
592            return Err(ReviewerError::VerdictSchema {
593                message: "PASS verdict must not include findings".to_owned(),
594            });
595        }
596        if self.verdict == Verdict::Reject && self.findings.is_empty() {
597            return Err(ReviewerError::VerdictSchema {
598                message: "REJECT verdict must include at least one finding".to_owned(),
599            });
600        }
601
602        Ok(())
603    }
604}
605
606#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
607#[serde(rename_all = "kebab-case")]
608pub enum ReviewRunStatus {
609    Queued,
610    Running,
611    Completed,
612    Failed,
613    Cancelled,
614}
615
616impl std::fmt::Display for ReviewRunStatus {
617    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
618        match self {
619            Self::Queued => formatter.write_str("queued"),
620            Self::Running => formatter.write_str("running"),
621            Self::Completed => formatter.write_str("completed"),
622            Self::Failed => formatter.write_str("failed"),
623            Self::Cancelled => formatter.write_str("cancelled"),
624        }
625    }
626}
627
628#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
629pub struct ReviewRun {
630    pub id: String,
631    pub commit_sha: String,
632    pub target: String,
633    pub status: ReviewRunStatus,
634    pub phase: String,
635    pub ledger_entries: usize,
636    pub error: Option<String>,
637    /// PID of the process executing this run while it is `Running`. Persisted so a
638    /// `review status`/`cancel` can tell a live worker from a silently-dead one and
639    /// reap the zombie. `None` once the run reaches a terminal state (or for legacy
640    /// records written before pid tracking existed).
641    #[serde(default)]
642    pub worker_pid: Option<u32>,
643    pub created_at_unix: u64,
644    pub updated_at_unix: u64,
645    pub started_at_unix: Option<u64>,
646    pub completed_at_unix: Option<u64>,
647    #[serde(default, skip_serializing_if = "Option::is_none")]
648    pub entire_checkpoint: Option<EntireCheckpointRef>,
649}
650
651#[derive(Clone, Debug, Default, Eq, PartialEq)]
652pub struct ReviewRunStatusCounts {
653    pub queued: usize,
654    pub running: usize,
655    pub completed: usize,
656    pub failed: usize,
657    pub cancelled: usize,
658    pub skipped_records: usize,
659}
660
661impl ReviewRunStatusCounts {
662    fn add(&mut self, status: ReviewRunStatus) {
663        match status {
664            ReviewRunStatus::Queued => self.queued += 1,
665            ReviewRunStatus::Running => self.running += 1,
666            ReviewRunStatus::Completed => self.completed += 1,
667            ReviewRunStatus::Failed => self.failed += 1,
668            ReviewRunStatus::Cancelled => self.cancelled += 1,
669        }
670    }
671}
672
673#[derive(Deserialize)]
674struct ReviewRunStatusRecord {
675    status: ReviewRunStatus,
676}
677
678impl ReviewRun {
679    #[cfg(test)]
680    fn queued(
681        id: impl Into<String>,
682        commit_sha: impl Into<String>,
683        target: impl Into<String>,
684    ) -> Self {
685        Self::queued_with_provenance(id, commit_sha, target, None)
686    }
687
688    fn queued_with_provenance(
689        id: impl Into<String>,
690        commit_sha: impl Into<String>,
691        target: impl Into<String>,
692        entire_checkpoint: Option<EntireCheckpointRef>,
693    ) -> Self {
694        let timestamp = unix_now();
695        Self {
696            id: id.into(),
697            commit_sha: commit_sha.into(),
698            target: target.into(),
699            status: ReviewRunStatus::Queued,
700            phase: "queued".to_owned(),
701            ledger_entries: 0,
702            error: None,
703            worker_pid: None,
704            created_at_unix: timestamp,
705            updated_at_unix: timestamp,
706            started_at_unix: None,
707            completed_at_unix: None,
708            entire_checkpoint,
709        }
710    }
711
712    fn mark_running(&mut self, phase: impl Into<String>) {
713        let timestamp = unix_now();
714        self.status = ReviewRunStatus::Running;
715        self.phase = phase.into();
716        self.error = None;
717        self.worker_pid = Some(std::process::id());
718        self.updated_at_unix = timestamp;
719        self.started_at_unix = Some(timestamp);
720        self.completed_at_unix = None;
721    }
722
723    fn mark_completed(&mut self, ledger_entries: usize) {
724        let timestamp = unix_now();
725        self.status = ReviewRunStatus::Completed;
726        self.phase = "completed".to_owned();
727        self.ledger_entries = ledger_entries;
728        self.error = None;
729        self.worker_pid = None;
730        self.updated_at_unix = timestamp;
731        self.completed_at_unix = Some(timestamp);
732    }
733
734    fn mark_failed(&mut self, error: impl Into<String>) {
735        let timestamp = unix_now();
736        self.status = ReviewRunStatus::Failed;
737        self.phase = "failed".to_owned();
738        self.error = Some(error.into());
739        self.worker_pid = None;
740        self.updated_at_unix = timestamp;
741        self.completed_at_unix = Some(timestamp);
742    }
743
744    fn mark_cancelled(&mut self) {
745        let timestamp = unix_now();
746        self.status = ReviewRunStatus::Cancelled;
747        self.phase = "cancelled".to_owned();
748        self.error = None;
749        self.worker_pid = None;
750        self.updated_at_unix = timestamp;
751        self.completed_at_unix = Some(timestamp);
752    }
753
754    /// Reconcile a `Running` run against process liveness. If its recorded worker
755    /// pid is gone, the worker died without recording a verdict — flip the zombie to
756    /// `Failed` with an explanatory reason. Returns whether the run was changed.
757    ///
758    /// A run with no recorded pid (legacy record) is left untouched: liveness cannot
759    /// be proven, so it is never auto-failed here — only an explicit forced cancel
760    /// reaps it.
761    fn reconcile_liveness(&mut self, is_alive: impl Fn(u32) -> bool) -> bool {
762        if self.status != ReviewRunStatus::Running {
763            return false;
764        }
765        match self.worker_pid {
766            Some(pid) if !is_alive(pid) => {
767                self.mark_failed(stale_worker_reason(pid));
768                true
769            }
770            _ => false,
771        }
772    }
773}
774
775/// Reason recorded when a running review is found abandoned by a dead worker.
776fn stale_worker_reason(pid: u32) -> String {
777    format!("worker process {pid} exited without recording a verdict (stale run)")
778}
779
780/// Whether a process with `pid` is currently alive.
781///
782/// Shells to `kill -0`, which probes for the process without delivering a signal:
783/// exit 0 means the process exists (for the current user, as truth-mirror workers
784/// always are); any non-zero exit or spawn failure is treated as not alive.
785fn pid_is_alive(pid: u32) -> bool {
786    Command::new("kill")
787        .arg("-0")
788        .arg(pid.to_string())
789        .stdout(Stdio::null())
790        .stderr(Stdio::null())
791        .status()
792        .map(|status| status.success())
793        .unwrap_or(false)
794}
795
796/// Force-terminate a process with SIGKILL. Used by `review cancel --force` on a
797/// worker that is still alive.
798fn kill_pid(pid: u32) -> Result<(), ReviewerError> {
799    let status = Command::new("kill")
800        .arg("-KILL")
801        .arg(pid.to_string())
802        .stdout(Stdio::null())
803        .stderr(Stdio::null())
804        .status()
805        .map_err(ReviewerError::KillWorker)?;
806    if status.success() || !pid_is_alive(pid) {
807        Ok(())
808    } else {
809        Err(ReviewerError::KillWorkerFailed { pid })
810    }
811}
812
813#[derive(Clone, Debug)]
814pub struct ReviewRunStore {
815    root: PathBuf,
816}
817
818impl ReviewRunStore {
819    pub fn new(root: impl Into<PathBuf>) -> Self {
820        Self { root: root.into() }
821    }
822
823    pub fn runs_dir(&self) -> PathBuf {
824        self.root.join(REVIEW_RUNS_DIR)
825    }
826
827    pub fn path(&self, id: &str) -> PathBuf {
828        self.runs_dir().join(format!("{id}.json"))
829    }
830
831    pub fn create_queued(
832        &self,
833        commit_sha: &str,
834        target: impl Into<String>,
835    ) -> Result<ReviewRun, ReviewerError> {
836        let checkpoint = entire_checkpoint_for_current_repo(commit_sha);
837        let run = ReviewRun::queued_with_provenance(
838            generate_run_id(commit_sha),
839            commit_sha,
840            target,
841            checkpoint,
842        );
843        self.write(&run)?;
844        Ok(run)
845    }
846
847    fn ensure_queued(
848        &self,
849        run_id: &str,
850        commit_sha: &str,
851        target: &str,
852    ) -> Result<ReviewRun, ReviewerError> {
853        match self.read(run_id) {
854            Ok(run) => Ok(run),
855            Err(ReviewerError::ReviewRunNotFound { .. }) => {
856                let checkpoint = entire_checkpoint_for_current_repo(commit_sha);
857                let run = ReviewRun::queued_with_provenance(run_id, commit_sha, target, checkpoint);
858                self.write(&run)?;
859                Ok(run)
860            }
861            Err(error) => Err(error),
862        }
863    }
864
865    pub fn read(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
866        let path = self.path(id);
867        let contents = fs::read_to_string(&path).map_err(|source| match source.kind() {
868            io::ErrorKind::NotFound => ReviewerError::ReviewRunNotFound { id: id.to_owned() },
869            _ => ReviewerError::RunIo(source),
870        })?;
871        serde_json::from_str(&contents).map_err(ReviewerError::RunJson)
872    }
873
874    pub fn list(&self) -> Result<Vec<ReviewRun>, ReviewerError> {
875        let dir = self.runs_dir();
876        let entries = match fs::read_dir(&dir) {
877            Ok(entries) => entries,
878            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
879            Err(error) => return Err(ReviewerError::RunIo(error)),
880        };
881        let mut runs: Vec<ReviewRun> = Vec::new();
882        for entry in entries {
883            let entry = entry.map_err(ReviewerError::RunIo)?;
884            if entry
885                .path()
886                .extension()
887                .is_none_or(|extension| extension != "json")
888            {
889                continue;
890            }
891            let contents = fs::read_to_string(entry.path()).map_err(ReviewerError::RunIo)?;
892            runs.push(serde_json::from_str(&contents).map_err(ReviewerError::RunJson)?);
893        }
894        runs.sort_by(|left, right| {
895            right
896                .updated_at_unix
897                .cmp(&left.updated_at_unix)
898                .then_with(|| right.id.cmp(&left.id))
899        });
900        Ok(runs)
901    }
902
903    pub fn status_counts(&self) -> Result<ReviewRunStatusCounts, ReviewerError> {
904        let dir = self.runs_dir();
905        let entries = match fs::read_dir(&dir) {
906            Ok(entries) => entries,
907            Err(error) if error.kind() == io::ErrorKind::NotFound => {
908                return Ok(ReviewRunStatusCounts::default());
909            }
910            Err(error) => return Err(ReviewerError::RunIo(error)),
911        };
912        let mut counts = ReviewRunStatusCounts::default();
913        for entry in entries {
914            let Ok(entry) = entry else {
915                counts.skipped_records += 1;
916                continue;
917            };
918            let path = entry.path();
919            if path.extension().is_none_or(|extension| extension != "json") {
920                continue;
921            }
922            let Ok(contents) = fs::read_to_string(&path) else {
923                counts.skipped_records += 1;
924                continue;
925            };
926            let Ok(record) = serde_json::from_str::<ReviewRunStatusRecord>(&contents) else {
927                counts.skipped_records += 1;
928                continue;
929            };
930            counts.add(record.status);
931        }
932        Ok(counts)
933    }
934
935    pub fn latest_result(&self) -> Result<ReviewRun, ReviewerError> {
936        self.list()?
937            .into_iter()
938            .find(|run| {
939                matches!(
940                    run.status,
941                    ReviewRunStatus::Completed
942                        | ReviewRunStatus::Failed
943                        | ReviewRunStatus::Cancelled
944                )
945            })
946            .ok_or(ReviewerError::NoReviewRuns)
947    }
948
949    pub fn mark_running(&self, id: &str, phase: &str) -> Result<ReviewRun, ReviewerError> {
950        let mut run = self.read(id)?;
951        run.mark_running(phase);
952        self.write(&run)?;
953        Ok(run)
954    }
955
956    pub fn mark_completed(
957        &self,
958        id: &str,
959        ledger_entries: usize,
960    ) -> Result<ReviewRun, ReviewerError> {
961        let mut run = self.read(id)?;
962        run.mark_completed(ledger_entries);
963        self.write(&run)?;
964        Ok(run)
965    }
966
967    pub fn mark_failed(
968        &self,
969        id: &str,
970        error: impl Into<String>,
971    ) -> Result<ReviewRun, ReviewerError> {
972        let mut run = self.read(id)?;
973        run.mark_failed(error);
974        self.write(&run)?;
975        Ok(run)
976    }
977
978    pub fn cancel_queued(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
979        let mut run = self.read(id)?;
980        if run.status != ReviewRunStatus::Queued {
981            return Err(ReviewerError::CannotCancelReview {
982                id: id.to_owned(),
983                status: run.status,
984            });
985        }
986        run.mark_cancelled();
987        self.write(&run)?;
988        Ok(run)
989    }
990
991    /// Read a run and, if it is a `Running` zombie (recorded worker pid is dead),
992    /// flip it to `Failed` and persist the change so `review status` reports the
993    /// truth instead of a run stuck `running` forever.
994    pub fn read_reconciled(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
995        let mut run = self.read(id)?;
996        if run.reconcile_liveness(pid_is_alive) {
997            self.write(&run)?;
998        }
999        Ok(run)
1000    }
1001
1002    /// List all runs, reconciling any `Running` zombies to `Failed` and persisting
1003    /// the correction, so a dead worker never lingers as `running`.
1004    pub fn list_reconciled(&self) -> Result<Vec<ReviewRun>, ReviewerError> {
1005        let mut runs = self.list()?;
1006        for run in &mut runs {
1007            if run.reconcile_liveness(pid_is_alive) {
1008                self.write(run)?;
1009            }
1010        }
1011        Ok(runs)
1012    }
1013
1014    /// Cancel a review run, tolerating a running-but-dead worker.
1015    ///
1016    /// - `Queued` runs cancel outright.
1017    /// - A `Running` run whose worker pid is dead is reaped (marked `Failed` stale),
1018    ///   so a zombie can always be cleaned up.
1019    /// - A `Running` run with a live worker is refused unless `force`, which SIGKILLs
1020    ///   the worker and then marks the run `Cancelled`.
1021    /// - A `Running` legacy run with no recorded pid can only be reaped with `force`.
1022    /// - Terminal runs (completed/failed/cancelled) are refused.
1023    pub fn cancel(&self, id: &str, force: bool) -> Result<ReviewRun, ReviewerError> {
1024        let mut run = self.read(id)?;
1025        match run.status {
1026            ReviewRunStatus::Queued => run.mark_cancelled(),
1027            ReviewRunStatus::Running => match run.worker_pid {
1028                Some(pid) if pid_is_alive(pid) => {
1029                    if !force {
1030                        return Err(ReviewerError::ReviewRunStillAlive {
1031                            id: id.to_owned(),
1032                            pid,
1033                        });
1034                    }
1035                    kill_pid(pid)?;
1036                    run.mark_cancelled();
1037                }
1038                Some(pid) => run.mark_failed(stale_worker_reason(pid)),
1039                None => {
1040                    if !force {
1041                        return Err(ReviewerError::ReviewRunLivenessUnknown { id: id.to_owned() });
1042                    }
1043                    run.mark_failed("worker liveness could not be verified; force-cancelled");
1044                }
1045            },
1046            terminal => {
1047                return Err(ReviewerError::CannotCancelReview {
1048                    id: id.to_owned(),
1049                    status: terminal,
1050                });
1051            }
1052        }
1053        self.write(&run)?;
1054        Ok(run)
1055    }
1056
1057    fn write(&self, run: &ReviewRun) -> Result<(), ReviewerError> {
1058        fs::create_dir_all(self.runs_dir()).map_err(ReviewerError::RunIo)?;
1059        let bytes = serde_json::to_vec_pretty(run).map_err(ReviewerError::RunJson)?;
1060        fs::write(self.path(&run.id), bytes).map_err(ReviewerError::RunIo)
1061    }
1062}
1063
1064fn entire_checkpoint_for_current_repo(commit_sha: &str) -> Option<EntireCheckpointRef> {
1065    let repo_root = current_repo_root().unwrap_or_else(|| PathBuf::from("."));
1066    provenance::entire_checkpoint_for_commit(&repo_root, commit_sha)
1067}
1068
1069fn current_repo_root() -> Option<PathBuf> {
1070    let output = Command::new("git")
1071        .args(["rev-parse", "--show-toplevel"])
1072        .output()
1073        .ok()?;
1074    output
1075        .status
1076        .success()
1077        .then(|| PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()))
1078}
1079
1080#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1081pub struct QueuedReview {
1082    #[serde(default)]
1083    pub run_id: String,
1084    pub commit_sha: String,
1085    pub enqueued_at_unix: u64,
1086}
1087
1088#[derive(Clone, Debug)]
1089pub struct ReviewQueue {
1090    root: PathBuf,
1091}
1092
1093#[derive(Clone, Debug, Default, Eq, PartialEq)]
1094pub struct ReviewQueueSummary {
1095    pub pending_count: usize,
1096    pub oldest_enqueued_at_unix: Option<u64>,
1097}
1098
1099impl ReviewQueueSummary {
1100    pub fn oldest_age_secs_at(&self, now: u64) -> Option<u64> {
1101        self.oldest_enqueued_at_unix
1102            .map(|oldest| now.saturating_sub(oldest))
1103    }
1104}
1105
1106impl ReviewQueue {
1107    pub fn new(root: impl Into<PathBuf>) -> Self {
1108        Self { root: root.into() }
1109    }
1110
1111    pub fn path(&self) -> PathBuf {
1112        self.root.join(REVIEW_QUEUE_FILE)
1113    }
1114
1115    pub fn enqueue(&self, commit_sha: impl Into<String>) -> Result<QueuedReview, ReviewerError> {
1116        fs::create_dir_all(&self.root).map_err(ReviewerError::QueueIo)?;
1117        let commit_sha = commit_sha.into();
1118        let run = ReviewRunStore::new(&self.root).create_queued(&commit_sha, "commit")?;
1119        let item = QueuedReview {
1120            run_id: run.id,
1121            commit_sha,
1122            enqueued_at_unix: unix_now(),
1123        };
1124        let mut file = fs::OpenOptions::new()
1125            .create(true)
1126            .append(true)
1127            .open(self.path())
1128            .map_err(ReviewerError::QueueIo)?;
1129        serde_json::to_writer(&mut file, &item).map_err(ReviewerError::QueueJson)?;
1130        writeln!(file).map_err(ReviewerError::QueueIo)?;
1131        Ok(item)
1132    }
1133
1134    pub fn pending(&self) -> Result<Vec<QueuedReview>, ReviewerError> {
1135        let contents = match fs::read_to_string(self.path()) {
1136            Ok(contents) => contents,
1137            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1138            Err(error) => return Err(ReviewerError::QueueIo(error)),
1139        };
1140
1141        contents
1142            .lines()
1143            .filter(|line| !line.trim().is_empty())
1144            .map(|line| serde_json::from_str(line).map_err(ReviewerError::QueueJson))
1145            .collect()
1146    }
1147
1148    pub fn summary(&self) -> Result<ReviewQueueSummary, ReviewerError> {
1149        let pending = self.pending()?;
1150        Ok(ReviewQueueSummary {
1151            pending_count: pending.len(),
1152            oldest_enqueued_at_unix: pending.iter().map(|item| item.enqueued_at_unix).min(),
1153        })
1154    }
1155
1156    /// Drop every queued item for `sha`, preserving any items appended for other
1157    /// commits. Called after a commit is reviewed so a drain never repeats work.
1158    pub fn remove_sha(&self, sha: &str) -> Result<(), ReviewerError> {
1159        let remaining: Vec<QueuedReview> = self
1160            .pending()?
1161            .into_iter()
1162            .filter(|item| item.commit_sha != sha)
1163            .collect();
1164        self.rewrite(&remaining)
1165    }
1166
1167    fn rewrite(&self, items: &[QueuedReview]) -> Result<(), ReviewerError> {
1168        if items.is_empty() {
1169            return match fs::remove_file(self.path()) {
1170                Ok(()) => Ok(()),
1171                Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1172                Err(error) => Err(ReviewerError::QueueIo(error)),
1173            };
1174        }
1175
1176        let mut file = fs::File::create(self.path()).map_err(ReviewerError::QueueIo)?;
1177        for item in items {
1178            serde_json::to_writer(&mut file, item).map_err(ReviewerError::QueueJson)?;
1179            writeln!(file).map_err(ReviewerError::QueueIo)?;
1180        }
1181        Ok(())
1182    }
1183
1184    pub fn remove_run_id(&self, run_id: &str) -> Result<(), ReviewerError> {
1185        let remaining: Vec<QueuedReview> = self
1186            .pending()?
1187            .into_iter()
1188            .filter(|item| item.run_id != run_id)
1189            .collect();
1190        self.rewrite(&remaining)
1191    }
1192}
1193
1194/// Loads the claim and diff for a commit so the reviewer can run against it.
1195/// Abstracted so `drain_once` can be unit-tested without a real git repository.
1196pub trait MaterialLoader {
1197    fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError>;
1198}
1199
1200#[derive(Clone, Debug, Default)]
1201pub struct GitMaterialLoader {
1202    /// Evidence-pointer patterns from config so async review parses claims the
1203    /// same way the commit-msg gate did (a repo `jira:` pointer stays valid).
1204    pub evidence_patterns: Vec<String>,
1205}
1206
1207impl GitMaterialLoader {
1208    pub fn with_patterns(evidence_patterns: Vec<String>) -> Self {
1209        Self { evidence_patterns }
1210    }
1211}
1212
1213impl MaterialLoader for GitMaterialLoader {
1214    fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError> {
1215        let message = git_output(["show", "--format=%B", "--no-patch", sha])?;
1216        let diff = git_output(["show", "--format=", "--patch", sha])?;
1217        let claim = if self.evidence_patterns.is_empty() {
1218            Claim::parse(&message)?
1219        } else {
1220            Claim::parse_with(&message, &self.evidence_patterns)?
1221        };
1222        Ok((claim, diff))
1223    }
1224}
1225
1226#[derive(Clone, Debug, Default, Eq, PartialEq)]
1227pub struct DrainReport {
1228    pub reviewed: Vec<String>,
1229    pub ledger_entries: usize,
1230}
1231
1232/// Review every distinct queued commit exactly once, record verdicts, and remove
1233/// each commit from the queue as soon as its review lands. A commit whose review
1234/// errors stays queued for the next drain.
1235pub fn drain_once<R: ProcessRunner, L: MaterialLoader>(
1236    queue: &ReviewQueue,
1237    loader: &L,
1238    selection: &ReviewSelection,
1239    context: &str,
1240    runner: &R,
1241    store: &LedgerStore,
1242    config: &config::TruthMirrorConfig,
1243) -> Result<DrainReport, ReviewerError> {
1244    let pending = queue.pending()?;
1245    let run_store = ReviewRunStore::new(&queue.root);
1246    let mut seen = std::collections::BTreeSet::new();
1247    let mut order = Vec::new();
1248    for item in &pending {
1249        if seen.insert(item.commit_sha.clone()) {
1250            order.push(item.clone());
1251        } else if !item.run_id.trim().is_empty()
1252            && let Ok(run) = run_store.read(&item.run_id)
1253            && run.status == ReviewRunStatus::Queued
1254        {
1255            run_store.cancel_queued(&item.run_id)?;
1256        }
1257    }
1258
1259    let mut report = DrainReport::default();
1260    for item in order {
1261        let sha = item.commit_sha;
1262        let run_id = if item.run_id.trim().is_empty() {
1263            generate_run_id(&sha)
1264        } else {
1265            item.run_id
1266        };
1267        let run = run_store.ensure_queued(&run_id, &sha, "commit")?;
1268        if run.status == ReviewRunStatus::Cancelled {
1269            queue.remove_sha(&sha)?;
1270            continue;
1271        }
1272        run_store.mark_running(&run_id, "reviewing")?;
1273        let (claim, diff) = loader.load(&sha)?;
1274        let prompt = first_pass_prompt(&claim, &diff, context);
1275        let job = ReviewJob {
1276            commit_sha: sha.clone(),
1277            claim,
1278            diff,
1279            context: context.to_owned(),
1280            request: selection.request_for(prompt),
1281            strict: selection.strict.clone(),
1282        };
1283        let execution = match execute_review_job(job, runner, store) {
1284            Ok(execution) => execution,
1285            Err(error) => {
1286                let _ = run_store.mark_failed(&run_id, error.to_string());
1287                return Err(error);
1288            }
1289        };
1290        record_memory_skill_outcome(
1291            &queue.root,
1292            config,
1293            &run_id,
1294            &sha,
1295            "watch-drain",
1296            &execution.entries,
1297        );
1298        report.ledger_entries += execution.entries.len();
1299        run_store.mark_completed(&run_id, execution.entries.len())?;
1300        queue.remove_sha(&sha)?;
1301        report.reviewed.push(sha);
1302    }
1303
1304    Ok(report)
1305}
1306
1307fn record_memory_skill_outcome(
1308    state_dir: &Path,
1309    config: &config::TruthMirrorConfig,
1310    run_id: &str,
1311    commit_sha: &str,
1312    phase: &str,
1313    entries: &[LedgerEntry],
1314) {
1315    if !is_full_git_sha(commit_sha) {
1316        tracing::debug!(
1317            run_id = %run_id,
1318            commit_sha = %commit_sha,
1319            phase = %phase,
1320            "memory-skill extraction skipped for non-commit review target"
1321        );
1322        return;
1323    }
1324    match crate::memory_skill::evaluate_review_completion(state_dir, config, run_id, entries) {
1325        Ok(_) => {}
1326        Err(crate::memory_skill::MemorySkillError::ScanRejected { reason }) => {
1327            tracing::info!(
1328                run_id = %run_id,
1329                commit_sha = %commit_sha,
1330                phase = %phase,
1331                memory_skill_outcome = "scan_rejected",
1332                reason = %reason,
1333                "memory-skill extraction skipped by scan gate"
1334            );
1335        }
1336        Err(error) => {
1337            let error_kind = memory_skill_error_kind(&error);
1338            tracing::warn!(
1339                run_id = %run_id,
1340                commit_sha = %commit_sha,
1341                phase = %phase,
1342                memory_skill_outcome = "failed",
1343                memory_skill_error_kind = error_kind,
1344                error = %error,
1345                "memory-skill extraction failed after review completion"
1346            );
1347        }
1348    }
1349}
1350
1351fn memory_skill_error_kind(error: &crate::memory_skill::MemorySkillError) -> &'static str {
1352    match error {
1353        crate::memory_skill::MemorySkillError::Io(_) => "io",
1354        crate::memory_skill::MemorySkillError::Json(_) => "json",
1355        crate::memory_skill::MemorySkillError::Ledger(_) => "ledger",
1356        crate::memory_skill::MemorySkillError::CandidateNotFound { .. } => "candidate_not_found",
1357        crate::memory_skill::MemorySkillError::AdvisoryNotFound { .. } => "advisory_not_found",
1358        crate::memory_skill::MemorySkillError::EmptyRejectReason => "empty_reject_reason",
1359        crate::memory_skill::MemorySkillError::EmptySupersedeReason => "empty_supersede_reason",
1360        crate::memory_skill::MemorySkillError::SelfSupersede { .. } => "self_supersede",
1361        crate::memory_skill::MemorySkillError::InvalidSupersedeReplacement { .. } => {
1362            "invalid_supersede_replacement"
1363        }
1364        crate::memory_skill::MemorySkillError::InvalidTransition { .. } => "invalid_transition",
1365        crate::memory_skill::MemorySkillError::TransitionLocked { .. } => "transition_locked",
1366        crate::memory_skill::MemorySkillError::UnsafeGlobalWrite { .. } => "unsafe_global_write",
1367        crate::memory_skill::MemorySkillError::UnsafeApprovedPath { .. } => "unsafe_approved_path",
1368        crate::memory_skill::MemorySkillError::UnsafeStatePath { .. } => "unsafe_state_path",
1369        crate::memory_skill::MemorySkillError::ScanRejected { .. } => "scan_rejected",
1370        crate::memory_skill::MemorySkillError::RenderedSkillTooLarge { .. } => {
1371            "rendered_skill_too_large"
1372        }
1373    }
1374}
1375
1376/// Build the ground-truth + trajectory context block for review prompts.
1377/// Best-effort: an unavailable repo or provider yields an empty block.
1378fn review_context(config: &config::TruthMirrorConfig) -> String {
1379    let repo_root = match git_output(["rev-parse", "--show-toplevel"]) {
1380        Ok(root) => PathBuf::from(root.trim()),
1381        Err(_) => return String::new(),
1382    };
1383    let provider = crate::context::trajectory_provider(&repo_root, &config.history);
1384    crate::context::build_review_context(
1385        &repo_root,
1386        &config.ground_truth,
1387        &config.history,
1388        Some(provider.as_ref()),
1389    )
1390    .unwrap_or_default()
1391}
1392
1393pub fn run_watch_command(
1394    args: cli::WatchArgs,
1395    state_dir: &Path,
1396    config: &config::TruthMirrorConfig,
1397) -> Result<ExitCode> {
1398    let selection = ReviewSelection::resolve(
1399        args.watched_agent,
1400        args.watched_model,
1401        args.reviewer_harness,
1402        args.reviewer_model,
1403        args.reviewer_effort,
1404        args.allow_same_model,
1405        config,
1406    )?;
1407    let queue = ReviewQueue::new(state_dir);
1408    let store = LedgerStore::new(state_dir);
1409    let loader = GitMaterialLoader::with_patterns(config.gates.to_policy().evidence_patterns);
1410    let runner = StdProcessRunner;
1411
1412    if args.once {
1413        let context = review_context(config);
1414        let report = drain_once(
1415            &queue, &loader, &selection, &context, &runner, &store, config,
1416        )?;
1417        println!(
1418            "truth-mirror watch: reviewed {} commit(s), wrote {} ledger entrie(s)",
1419            report.reviewed.len(),
1420            report.ledger_entries
1421        );
1422        return Ok(ExitCode::SUCCESS);
1423    }
1424
1425    let interval = std::time::Duration::from_secs(args.poll_secs.max(1));
1426    loop {
1427        // Rebuild context each poll so ground truth and trajectory stay current.
1428        let context = review_context(config);
1429        let report = drain_once(
1430            &queue, &loader, &selection, &context, &runner, &store, config,
1431        )?;
1432        if !report.reviewed.is_empty() {
1433            println!(
1434                "truth-mirror watch: reviewed {} commit(s)",
1435                report.reviewed.len()
1436            );
1437        }
1438        std::thread::sleep(interval);
1439    }
1440}
1441
1442#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1443pub struct StrictGoalPolicy {
1444    pub stop_after_lies: u32,
1445    pub stop_after_fuckups: u32,
1446}
1447
1448#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1449pub struct StrictGoalCounters {
1450    pub lies_exposed: u32,
1451    pub fuckups_registered: u32,
1452}
1453
1454#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1455pub enum StrictGoalDecision {
1456    Continue,
1457    Stop { reason: StrictGoalStopReason },
1458}
1459
1460#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1461pub enum StrictGoalStopReason {
1462    LiesExposed,
1463    FuckupsRegistered,
1464}
1465
1466impl StrictGoalPolicy {
1467    pub fn decide(&self, counters: StrictGoalCounters) -> StrictGoalDecision {
1468        if self.stop_after_lies > 0 && counters.lies_exposed >= self.stop_after_lies {
1469            return StrictGoalDecision::Stop {
1470                reason: StrictGoalStopReason::LiesExposed,
1471            };
1472        }
1473
1474        if self.stop_after_fuckups > 0 && counters.fuckups_registered >= self.stop_after_fuckups {
1475            return StrictGoalDecision::Stop {
1476                reason: StrictGoalStopReason::FuckupsRegistered,
1477            };
1478        }
1479
1480        StrictGoalDecision::Continue
1481    }
1482}
1483
1484#[derive(Clone, Debug, Eq, PartialEq)]
1485pub struct StrictGoalOutcome {
1486    pub passes: u32,
1487    pub counters: StrictGoalCounters,
1488    /// `None` means the loop stopped at the `max_passes` ceiling rather than
1489    /// hitting a configured lie/fuckup threshold.
1490    pub stop_reason: Option<StrictGoalStopReason>,
1491    pub entries: Vec<LedgerEntry>,
1492}
1493
1494impl StrictGoalOutcome {
1495    pub fn stop_reason_suffix(&self) -> &'static str {
1496        match self.stop_reason {
1497            Some(StrictGoalStopReason::LiesExposed) => " (stopped: lies exposed)",
1498            Some(StrictGoalStopReason::FuckupsRegistered) => " (stopped: fuckups registered)",
1499            None => " (stopped: max passes)",
1500        }
1501    }
1502}
1503
1504/// Sic the adversarial reviewer on a commit in a loop, accumulating exposed lies
1505/// (REJECT verdicts) and registered fuckups (individual findings). Every pass is
1506/// recorded in the ledger. The loop stops when `policy` says the configured `N`
1507/// is reached, or when `max_passes` is hit so an honest agent still terminates.
1508#[allow(clippy::too_many_arguments)]
1509pub fn run_strict_goal_loop<R: ProcessRunner>(
1510    commit_sha: &str,
1511    claim: &Claim,
1512    diff: &str,
1513    context: &str,
1514    selection: &ReviewSelection,
1515    policy: StrictGoalPolicy,
1516    max_passes: u32,
1517    runner: &R,
1518    store: &LedgerStore,
1519) -> Result<StrictGoalOutcome, ReviewerError> {
1520    let ceiling = max_passes.max(1);
1521    let mut outcome = StrictGoalOutcome {
1522        passes: 0,
1523        counters: StrictGoalCounters {
1524            lies_exposed: 0,
1525            fuckups_registered: 0,
1526        },
1527        stop_reason: None,
1528        entries: Vec::new(),
1529    };
1530
1531    while outcome.passes < ceiling {
1532        let prompt = strict_goal_prompt(claim, diff, context, outcome.passes + 1, &outcome.entries);
1533        let request = selection.request_for(prompt);
1534        let plan = ReviewPlan::build(request.clone())?;
1535        let output = plan.run_with(&request.prompt, runner)?;
1536        ensure_process_success(&output)?;
1537        let verdict = ParsedVerdict::parse(&output.stdout)?;
1538
1539        let job = ReviewJob {
1540            commit_sha: commit_sha.to_owned(),
1541            claim: claim.clone(),
1542            diff: diff.to_owned(),
1543            context: context.to_owned(),
1544            request,
1545            strict: None,
1546        };
1547        let entry = entry_from_verdict(&job, &plan, &verdict);
1548        store.append_entry(&entry)?;
1549        outcome.entries.push(entry);
1550
1551        outcome.passes += 1;
1552        if verdict.verdict == Verdict::Reject {
1553            outcome.counters.lies_exposed += 1;
1554        }
1555        outcome.counters.fuckups_registered = outcome
1556            .counters
1557            .fuckups_registered
1558            .saturating_add(u32::try_from(verdict.findings.len()).unwrap_or(u32::MAX));
1559
1560        if let StrictGoalDecision::Stop { reason } = policy.decide(outcome.counters) {
1561            outcome.stop_reason = Some(reason);
1562            break;
1563        }
1564    }
1565
1566    Ok(outcome)
1567}
1568
1569fn strict_goal_prompt(
1570    claim: &Claim,
1571    diff: &str,
1572    context: &str,
1573    pass: u32,
1574    prior: &[LedgerEntry],
1575) -> String {
1576    let prior_findings: Vec<String> = prior
1577        .iter()
1578        .flat_map(|entry| entry.findings.clone())
1579        .collect();
1580    let prior_block = if prior_findings.is_empty() {
1581        "(none)".to_owned()
1582    } else {
1583        prior_findings.join("\n")
1584    };
1585    format!(
1586        "{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{}",
1587        context_block(context),
1588        claim.to_line(),
1589        diff
1590    )
1591}
1592
1593pub fn run_review_command(
1594    args: cli::ReviewArgs,
1595    state_dir: &Path,
1596    config: &config::TruthMirrorConfig,
1597) -> Result<ExitCode> {
1598    if let Some(command) = args.command {
1599        return run_review_run_command(command, state_dir);
1600    }
1601
1602    let material = ReviewMaterial::load(
1603        &args,
1604        state_dir,
1605        &config.gates.to_policy().evidence_patterns,
1606    )?;
1607
1608    let mut selection = ReviewSelection::resolve(
1609        args.watched_agent,
1610        args.watched_model,
1611        args.reviewer_harness,
1612        args.reviewer_model,
1613        args.reviewer_effort,
1614        args.allow_same_model,
1615        config,
1616    )?;
1617
1618    if args.strict_two_pass {
1619        selection.strict = Some(ReviewSelection::resolve_arbiter(
1620            selection.watched_agent,
1621            args.arbiter_harness,
1622            args.arbiter_model,
1623            args.arbiter_effort,
1624            config,
1625        )?);
1626    }
1627    let store = LedgerStore::new(state_dir);
1628    let run_store = ReviewRunStore::new(state_dir);
1629    let context = review_context(config);
1630    let run = run_store.create_queued(&material.commit_sha, material.target_label.clone())?;
1631    run_store.mark_running(&run.id, "reviewing")?;
1632
1633    if args.strict_goal {
1634        let policy = config
1635            .strict
1636            .goal_policy(args.stop_after_lies, args.stop_after_fuckups);
1637        let max_passes = args.max_passes.unwrap_or(config.strict.max_passes);
1638        let outcome = match run_strict_goal_loop(
1639            &material.commit_sha,
1640            &material.claim,
1641            &material.diff,
1642            &context,
1643            &selection,
1644            policy,
1645            max_passes,
1646            &StdProcessRunner,
1647            &store,
1648        ) {
1649            Ok(outcome) => outcome,
1650            Err(error) => {
1651                let _ = run_store.mark_failed(&run.id, error.to_string());
1652                return Err(error.into());
1653            }
1654        };
1655        record_memory_skill_outcome(
1656            state_dir,
1657            config,
1658            &run.id,
1659            &material.commit_sha,
1660            "strict-goal",
1661            &outcome.entries,
1662        );
1663        run_store.mark_completed(&run.id, outcome.entries.len())?;
1664        println!(
1665            "truth-mirror strict-goal: run {}, {} pass(es), {} lie(s), {} fuckup(s){}",
1666            run.id,
1667            outcome.passes,
1668            outcome.counters.lies_exposed,
1669            outcome.counters.fuckups_registered,
1670            outcome.stop_reason_suffix(),
1671        );
1672        return Ok(ExitCode::SUCCESS);
1673    }
1674
1675    let prompt = first_pass_prompt(&material.claim, &material.diff, &context);
1676    let commit_sha = material.commit_sha.clone();
1677    let job = ReviewJob {
1678        commit_sha: material.commit_sha,
1679        claim: material.claim,
1680        diff: material.diff,
1681        context,
1682        request: selection.request_for(prompt),
1683        strict: selection.strict.clone(),
1684    };
1685
1686    let execution = match execute_review_job(job, &StdProcessRunner, &store) {
1687        Ok(execution) => execution,
1688        Err(error) => {
1689            let _ = run_store.mark_failed(&run.id, error.to_string());
1690            return Err(error.into());
1691        }
1692    };
1693    record_memory_skill_outcome(
1694        state_dir,
1695        config,
1696        &run.id,
1697        &commit_sha,
1698        "manual-review",
1699        &execution.entries,
1700    );
1701    run_store.mark_completed(&run.id, execution.entries.len())?;
1702    println!(
1703        "truth-mirror review: run {}, wrote {} ledger entrie(s)",
1704        run.id,
1705        execution.entries.len()
1706    );
1707    Ok(ExitCode::SUCCESS)
1708}
1709
1710fn run_review_run_command(command: cli::ReviewCommand, state_dir: &Path) -> Result<ExitCode> {
1711    let runs = ReviewRunStore::new(state_dir);
1712    match command {
1713        cli::ReviewCommand::Status { run_id } => {
1714            if let Some(run_id) = run_id {
1715                print_run(&runs.read_reconciled(&run_id)?);
1716            } else {
1717                let all = runs.list_reconciled()?;
1718                if all.is_empty() {
1719                    println!("No review runs.");
1720                } else {
1721                    for run in all {
1722                        print_run_summary(&run);
1723                    }
1724                }
1725            }
1726        }
1727        cli::ReviewCommand::Result { run_id } => {
1728            let run = match run_id {
1729                Some(run_id) => runs.read(&run_id)?,
1730                None => runs.latest_result()?,
1731            };
1732            print_run(&run);
1733            print_run_ledger_entries(state_dir, &run)?;
1734        }
1735        cli::ReviewCommand::Cancel { run_id, force } => {
1736            let run = runs.cancel(&run_id, force)?;
1737            ReviewQueue::new(state_dir).remove_run_id(&run_id)?;
1738            match run.status {
1739                ReviewRunStatus::Failed => println!(
1740                    "reaped stale review run {} ({}): {}",
1741                    run.id,
1742                    run.commit_sha,
1743                    run.error.as_deref().unwrap_or("worker was not alive"),
1744                ),
1745                _ => println!("cancelled review run {} ({})", run.id, run.commit_sha),
1746            }
1747        }
1748    }
1749    Ok(ExitCode::SUCCESS)
1750}
1751
1752fn print_run_summary(run: &ReviewRun) {
1753    println!(
1754        "{} {} {} {} entries={} updated={}",
1755        run.id, run.status, run.commit_sha, run.phase, run.ledger_entries, run.updated_at_unix
1756    );
1757}
1758
1759fn print_run(run: &ReviewRun) {
1760    println!("run: {}", run.id);
1761    println!("status: {}", run.status);
1762    println!("commit: {}", run.commit_sha);
1763    println!("target: {}", run.target);
1764    println!("phase: {}", run.phase);
1765    println!("ledger_entries: {}", run.ledger_entries);
1766    if let Some(pid) = run.worker_pid {
1767        println!("worker_pid: {pid}");
1768    }
1769    println!("created_at_unix: {}", run.created_at_unix);
1770    println!("updated_at_unix: {}", run.updated_at_unix);
1771    if let Some(started) = run.started_at_unix {
1772        println!("started_at_unix: {started}");
1773    }
1774    if let Some(completed) = run.completed_at_unix {
1775        println!("completed_at_unix: {completed}");
1776    }
1777    if let Some(error) = &run.error {
1778        println!("error: {error}");
1779    }
1780    if let Some(checkpoint) = &run.entire_checkpoint {
1781        println!("entire_ref: {}", checkpoint.ref_name);
1782        println!("entire_sha: {}", checkpoint.object_sha);
1783    }
1784}
1785
1786fn print_run_ledger_entries(state_dir: &Path, run: &ReviewRun) -> Result<(), ReviewerError> {
1787    let store = LedgerStore::new(state_dir);
1788    let entries: Vec<LedgerEntry> = store
1789        .read_history()?
1790        .into_iter()
1791        .filter(|entry| entry.commit_sha == run.commit_sha)
1792        .collect();
1793    if entries.is_empty() {
1794        println!("ledger_entries: none");
1795        return Ok(());
1796    }
1797    println!("ledger_entries:");
1798    for entry in entries {
1799        println!(
1800            "- {} {} {} findings={}",
1801            entry.commit_sha,
1802            entry.verdict,
1803            entry.disposition,
1804            entry.findings.len()
1805        );
1806    }
1807    Ok(())
1808}
1809
1810#[derive(Clone, Debug, Eq, PartialEq)]
1811struct ReviewMaterial {
1812    commit_sha: String,
1813    target_label: String,
1814    claim: Claim,
1815    diff: String,
1816}
1817
1818impl ReviewMaterial {
1819    fn load(
1820        args: &cli::ReviewArgs,
1821        state_dir: &Path,
1822        evidence_patterns: &[String],
1823    ) -> Result<Self, ReviewerError> {
1824        let parse = |text: &str| {
1825            if evidence_patterns.is_empty() {
1826                Claim::parse(text)
1827            } else {
1828                Claim::parse_with(text, evidence_patterns)
1829            }
1830        };
1831
1832        let scope = if args.staged {
1833            ReviewScope::Staged
1834        } else {
1835            args.scope
1836        };
1837
1838        match scope {
1839            ReviewScope::Commit => {
1840                let target = args
1841                    .target
1842                    .clone()
1843                    .ok_or(ReviewerError::MissingReviewTarget)?;
1844                let sha = resolve_commit_target(&target)?;
1845                let message = git_output(["show", "--format=%B", "--no-patch", sha.as_str()])?;
1846                let diff = git_output(["show", "--format=", "--patch", sha.as_str()])?;
1847                let claim = parse(&message)?;
1848                Ok(Self {
1849                    commit_sha: sha.clone(),
1850                    target_label: format!("commit:{target}"),
1851                    claim,
1852                    diff,
1853                })
1854            }
1855            ReviewScope::Staged => Self::load_staged(state_dir, &parse),
1856            ReviewScope::Auto => {
1857                reject_target_with_scope(args)?;
1858                if working_tree_dirty()? {
1859                    Self::load_working_tree(state_dir, &parse)
1860                } else {
1861                    Self::load_branch(args.base.as_deref(), &parse)
1862                }
1863            }
1864            ReviewScope::WorkingTree => {
1865                reject_target_with_scope(args)?;
1866                Self::load_working_tree(state_dir, &parse)
1867            }
1868            ReviewScope::Branch => {
1869                reject_target_with_scope(args)?;
1870                Self::load_branch(args.base.as_deref(), &parse)
1871            }
1872        }
1873    }
1874
1875    fn load_staged<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
1876    where
1877        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
1878    {
1879        let raw = git_output(["diff", "--cached"])?;
1880        let files = git_output(["diff", "--cached", "--name-only"])?;
1881        let diff = materialize_diff("staged", &raw, &files);
1882        let claim = parse(&read_claim_file(state_dir)?)?;
1883        Ok(Self {
1884            commit_sha: "STAGED".to_owned(),
1885            target_label: "staged".to_owned(),
1886            claim,
1887            diff,
1888        })
1889    }
1890
1891    fn load_working_tree<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
1892    where
1893        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
1894    {
1895        let status = git_output(["status", "--porcelain"])?;
1896        let tracked = git_output(["diff", "HEAD", "--patch"])?;
1897        let files = git_output(["diff", "HEAD", "--name-only"])?;
1898        let untracked = untracked_file_context()?;
1899        let raw = format!(
1900            "WORKING TREE STATUS:\n{status}\n\nTRACKED DIFF AGAINST HEAD:\n{tracked}\n\nUNTRACKED FILES:\n{untracked}"
1901        );
1902        let diff = materialize_diff("working-tree", &raw, &files);
1903        let claim = parse(&read_claim_file(state_dir)?)?;
1904        Ok(Self {
1905            commit_sha: "WORKING_TREE".to_owned(),
1906            target_label: "working-tree".to_owned(),
1907            claim,
1908            diff,
1909        })
1910    }
1911
1912    fn load_branch<F>(base: Option<&str>, parse: &F) -> Result<Self, ReviewerError>
1913    where
1914        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
1915    {
1916        let base = match base {
1917            Some(base) => base.to_owned(),
1918            None => default_branch_ref()?,
1919        };
1920        let merge_base = git_output_slice(&["merge-base", "HEAD", &base])?;
1921        let merge_base = merge_base.trim().to_owned();
1922        let range = format!("{merge_base}..HEAD");
1923        let message = git_output(["show", "--format=%B", "--no-patch", "HEAD"])?;
1924        let log = git_output_slice(&["log", "--oneline", &range])?;
1925        let stat = git_output_slice(&["diff", "--stat", &range])?;
1926        let raw_patch = git_output_slice(&["diff", "--patch", &range])?;
1927        let files = git_output_slice(&["diff", "--name-only", &range])?;
1928        let raw = format!(
1929            "BRANCH BASE: {base}\nMERGE BASE: {merge_base}\nCOMMITS:\n{log}\n\nDIFF STAT:\n{stat}\n\nDIFF:\n{raw_patch}"
1930        );
1931        let diff = materialize_diff(&format!("branch:{base}"), &raw, &files);
1932        let claim = parse(&message)?;
1933        Ok(Self {
1934            commit_sha: "HEAD".to_owned(),
1935            target_label: format!("branch:{base}"),
1936            claim,
1937            diff,
1938        })
1939    }
1940}
1941
1942fn resolve_commit_target(target: &str) -> Result<String, ReviewerError> {
1943    let rev = format!("{target}^{{commit}}");
1944    Ok(
1945        git_output_slice(&["rev-parse", "--verify", "--quiet", "--end-of-options", &rev])?
1946            .trim()
1947            .to_owned(),
1948    )
1949}
1950
1951fn reject_target_with_scope(args: &cli::ReviewArgs) -> Result<(), ReviewerError> {
1952    if let Some(target) = &args.target {
1953        return Err(ReviewerError::UnexpectedReviewTarget {
1954            scope: args.scope,
1955            target: target.clone(),
1956        });
1957    }
1958    Ok(())
1959}
1960
1961fn read_claim_file(state_dir: &Path) -> Result<String, ReviewerError> {
1962    let claim_path = state_dir.join("claim.txt");
1963    fs::read_to_string(&claim_path).map_err(|source| ReviewerError::ClaimFileRead {
1964        path: claim_path,
1965        source,
1966    })
1967}
1968
1969fn working_tree_dirty() -> Result<bool, ReviewerError> {
1970    Ok(!git_output(["status", "--porcelain"])?.trim().is_empty())
1971}
1972
1973fn default_branch_ref() -> Result<String, ReviewerError> {
1974    if let Ok(symbolic) = git_output([
1975        "symbolic-ref",
1976        "--quiet",
1977        "--short",
1978        "refs/remotes/origin/HEAD",
1979    ]) {
1980        let trimmed = symbolic.trim();
1981        if !trimmed.is_empty() {
1982            return Ok(trimmed.to_owned());
1983        }
1984    }
1985
1986    for candidate in [
1987        "origin/main",
1988        "origin/master",
1989        "origin/trunk",
1990        "main",
1991        "master",
1992        "trunk",
1993    ] {
1994        if git_output_slice(&["rev-parse", "--verify", "--quiet", candidate]).is_ok() {
1995            return Ok(candidate.to_owned());
1996        }
1997    }
1998
1999    Err(ReviewerError::DefaultBranchNotFound)
2000}
2001
2002fn materialize_diff(label: &str, raw: &str, files: &str) -> String {
2003    let file_list: Vec<&str> = files
2004        .lines()
2005        .filter(|line| !line.trim().is_empty())
2006        .collect();
2007    let bytes = raw.len();
2008    if bytes <= MAX_INLINE_DIFF_BYTES && file_list.len() <= MAX_INLINE_DIFF_FILES {
2009        return raw.to_owned();
2010    }
2011
2012    format!(
2013        "Diff for {label} is too large to inline safely.\ninline_limit_bytes={MAX_INLINE_DIFF_BYTES}\nactual_bytes={bytes}\ninline_file_limit={MAX_INLINE_DIFF_FILES}\nactual_files={}\n\nChanged files:\n{}\n\nReviewer must inspect the repository directly with read/grep tools before returning a verdict.",
2014        file_list.len(),
2015        if file_list.is_empty() {
2016            "(none)".to_owned()
2017        } else {
2018            file_list.join("\n")
2019        }
2020    )
2021}
2022
2023fn is_full_git_sha(value: &str) -> bool {
2024    matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2025}
2026
2027fn untracked_file_context() -> Result<String, ReviewerError> {
2028    let files = git_output(["ls-files", "--others", "--exclude-standard"])?;
2029    let mut output = String::new();
2030    for file in files.lines().filter(|line| !line.trim().is_empty()) {
2031        let path = Path::new(file);
2032        let metadata = match fs::metadata(path) {
2033            Ok(metadata) => metadata,
2034            Err(_) => continue,
2035        };
2036        if !metadata.is_file() {
2037            continue;
2038        }
2039        if metadata.len() > MAX_UNTRACKED_FILE_BYTES {
2040            output.push_str(&format!(
2041                "\n--- {file} omitted: {} bytes exceeds {MAX_UNTRACKED_FILE_BYTES} byte inline limit ---\n",
2042                metadata.len()
2043            ));
2044            continue;
2045        }
2046        let bytes = match fs::read(path) {
2047            Ok(bytes) => bytes,
2048            Err(_) => continue,
2049        };
2050        if bytes.contains(&0) {
2051            output.push_str(&format!("\n--- {file} omitted: binary file ---\n"));
2052            continue;
2053        }
2054        output.push_str(&format!(
2055            "\n--- {file} ---\n{}",
2056            String::from_utf8_lossy(&bytes)
2057        ));
2058    }
2059
2060    if output.is_empty() {
2061        Ok("(none)".to_owned())
2062    } else {
2063        Ok(output)
2064    }
2065}
2066
2067#[derive(Debug, Error)]
2068pub enum ReviewerError {
2069    #[error("missing {role} model")]
2070    MissingModel { role: String },
2071    #[error(
2072        "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
2073    )]
2074    SameModelWithoutWaiver {
2075        watched_model: String,
2076        reviewer_model: String,
2077    },
2078    #[error("strict arbiter model must differ from watched and first reviewer models")]
2079    StrictArbiterModelNotDistinct,
2080    #[error("no adversarial pair configured for writer harness {writer:?}")]
2081    NoPairForWriter { writer: String },
2082    #[error(
2083        "strict review requires an arbiter (pair.arbiter or --arbiter-harness/--arbiter-model)"
2084    )]
2085    MissingArbiter,
2086    #[error(
2087        "--{role}-harness={harness:?} was overridden without a matching --{role}-model; the pair's model is for a different harness"
2088    )]
2089    OverrideNeedsModel { role: String, harness: String },
2090    #[error("custom reviewer harness requires explicit command configuration")]
2091    UnsupportedCustomHarness,
2092    #[error("unknown watched agent {value:?}")]
2093    UnknownAgent { value: String },
2094    #[error("unknown reviewer harness {value:?}")]
2095    UnknownHarness { value: String },
2096    #[error("missing review target")]
2097    MissingReviewTarget,
2098    #[error("--scope={scope:?} does not accept positional target {target:?}")]
2099    UnexpectedReviewTarget { scope: ReviewScope, target: String },
2100    #[error("could not determine default branch; pass --base explicitly")]
2101    DefaultBranchNotFound,
2102    #[error("failed to read staged claim file {path}: {source}")]
2103    ClaimFileRead {
2104        path: PathBuf,
2105        #[source]
2106        source: io::Error,
2107    },
2108    #[error("reviewer output was not valid structured JSON verdict: {source}: {output:?}")]
2109    VerdictJson {
2110        source: serde_json::Error,
2111        output: String,
2112    },
2113    #[error("reviewer structured verdict violated schema: {message}")]
2114    VerdictSchema { message: String },
2115    #[error("reviewer process exited with status {status:?}: {stderr}")]
2116    ReviewerProcessFailed { status: Option<i32>, stderr: String },
2117    #[error("git command failed: git {args:?}: {stderr}")]
2118    GitFailed { args: Vec<String>, stderr: String },
2119    #[error("failed to spawn git command: {0}")]
2120    GitSpawn(io::Error),
2121    #[error("failed to spawn reviewer process: {0}")]
2122    Spawn(io::Error),
2123    #[error("failed to open reviewer stdin pipe")]
2124    MissingStdinPipe,
2125    #[error("failed to write reviewer prompt: {0}")]
2126    WritePrompt(io::Error),
2127    #[error("failed to wait for reviewer process: {0}")]
2128    Wait(io::Error),
2129    #[error("review queue IO failed: {0}")]
2130    QueueIo(io::Error),
2131    #[error("review queue JSON failed: {0}")]
2132    QueueJson(serde_json::Error),
2133    #[error("review run IO failed: {0}")]
2134    RunIo(io::Error),
2135    #[error("review run JSON failed: {0}")]
2136    RunJson(serde_json::Error),
2137    #[error("review run not found: {id}")]
2138    ReviewRunNotFound { id: String },
2139    #[error("no review runs found")]
2140    NoReviewRuns,
2141    #[error("cannot cancel review run {id} with status {status}; it has already finished")]
2142    CannotCancelReview { id: String, status: ReviewRunStatus },
2143    #[error(
2144        "review run {id} is still running (worker pid {pid} is alive); pass --force to kill it"
2145    )]
2146    ReviewRunStillAlive { id: String, pid: u32 },
2147    #[error(
2148        "review run {id} is running but records no worker pid; pass --force to reap it if it is stuck"
2149    )]
2150    ReviewRunLivenessUnknown { id: String },
2151    #[error("failed to spawn kill for stale worker: {0}")]
2152    KillWorker(io::Error),
2153    #[error("failed to kill worker process {pid}")]
2154    KillWorkerFailed { pid: u32 },
2155    #[error(transparent)]
2156    Claim(#[from] crate::claim::ClaimError),
2157    #[error(transparent)]
2158    Ledger(#[from] crate::ledger::LedgerError),
2159}
2160
2161const ADVERSARIAL_PREAMBLE: &str = r#"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.
2162
2163Attack the change for auth and permission holes, data loss, rollback gaps, races, stale state, version skew, observability gaps, missing evidence, fake evidence, broad matchers, gates that fail open, and code that only fixes the instance instead of the defect class.
2164
2165GREP THE CLASS, NOT THE INSTANCE. For every problem you find, do NOT stop at the one occurrence: name the general CLASS of the defect (for example, 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 in finding bodies when relevant.
2166
2167Return valid JSON only. Do not wrap it in Markdown. The schema is:
2168{
2169  "verdict": "PASS" | "REJECT",
2170  "summary": "one concise sentence explaining why the claim passes or fails",
2171  "findings": [
2172    {
2173      "severity": "critical" | "high" | "medium" | "low",
2174      "title": "short defect title",
2175      "body": "what can go wrong, why this code is vulnerable, and what evidence proves it",
2176      "file": "repo-relative file path",
2177      "line_start": 1,
2178      "line_end": 1,
2179      "confidence": 0,
2180      "recommendation": "concrete change required"
2181    }
2182  ],
2183  "next_steps": ["short concrete follow-up commands or edits"],
2184  "memory_skill": {
2185    "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2186    "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2187    "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2188  }
2189}
2190
2191For "memory_skill", propose "how_to_skill" only for reusable verified PASS procedures, "anti_pattern_skill" for repeated false-claim or evidence failure classes, "remediation_skill" for repeated repair workflows, and "none" when the reviewed change is not reusable procedural memory. Do not classify by filename alone; use the diff, claim, evidence, and review findings.
2192
2193Use "PASS" only when there are no findings. Use "REJECT" when there is at least one material finding."#;
2194
2195fn context_block(context: &str) -> String {
2196    if context.trim().is_empty() {
2197        String::new()
2198    } else {
2199        format!("\n\n{context}")
2200    }
2201}
2202
2203fn first_pass_prompt(claim: &Claim, diff: &str, context: &str) -> String {
2204    format!(
2205        "{ADVERSARIAL_PREAMBLE}{}\n\nCLAIM:\n{}\n\nDIFF:\n{}",
2206        context_block(context),
2207        claim.to_line(),
2208        diff
2209    )
2210}
2211
2212fn strict_second_pass_prompt(job: &ReviewJob, first_output: &str) -> String {
2213    format!(
2214        "{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{}",
2215        context_block(&job.context),
2216        job.claim.to_line(),
2217        first_output,
2218        job.diff
2219    )
2220}
2221
2222fn entry_from_verdict(job: &ReviewJob, plan: &ReviewPlan, verdict: &ParsedVerdict) -> LedgerEntry {
2223    LedgerEntry::new(
2224        job.commit_sha.clone(),
2225        verdict.verdict,
2226        job.claim.to_line(),
2227        job.claim
2228            .evidence
2229            .iter()
2230            .map(EvidenceRef::as_str)
2231            .map(str::to_owned)
2232            .collect(),
2233        plan.reviewer_config(),
2234        verdict.findings.clone(),
2235    )
2236    .with_structured_review(
2237        verdict.summary.clone(),
2238        verdict.structured_findings.clone(),
2239        verdict.next_steps.clone(),
2240        verdict.raw.clone(),
2241    )
2242    .with_memory_skill_classification(verdict.memory_skill_classification.clone())
2243}
2244
2245fn ensure_process_success(output: &ProcessOutput) -> Result<(), ReviewerError> {
2246    if output.status_code == Some(0) {
2247        return Ok(());
2248    }
2249
2250    Err(ReviewerError::ReviewerProcessFailed {
2251        status: output.status_code,
2252        stderr: output.stderr.clone(),
2253    })
2254}
2255
2256fn validate_strict_arbiter(
2257    request: &ReviewRequest,
2258    strict: &StrictReviewConfig,
2259) -> Result<(), ReviewerError> {
2260    let arbiter = normalized_model(&strict.arbiter_model);
2261    if arbiter == normalized_model(&request.watched_model)
2262        || arbiter == normalized_model(&request.reviewer_model)
2263    {
2264        return Err(ReviewerError::StrictArbiterModelNotDistinct);
2265    }
2266    Ok(())
2267}
2268
2269fn validate_model_present(role: &str, model: &str) -> Result<(), ReviewerError> {
2270    if model.trim().is_empty() {
2271        return Err(ReviewerError::MissingModel {
2272            role: role.to_owned(),
2273        });
2274    }
2275    Ok(())
2276}
2277
2278fn git_output<const N: usize>(args: [&str; N]) -> Result<String, ReviewerError> {
2279    git_output_slice(&args)
2280}
2281
2282fn git_output_slice(args: &[&str]) -> Result<String, ReviewerError> {
2283    let output = Command::new("git")
2284        .args(args)
2285        .output()
2286        .map_err(ReviewerError::GitSpawn)?;
2287    if !output.status.success() {
2288        return Err(ReviewerError::GitFailed {
2289            args: args.iter().map(|arg| (*arg).to_owned()).collect(),
2290            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2291        });
2292    }
2293
2294    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
2295}
2296
2297fn agent_from_slug(value: &str) -> Result<Agent, ReviewerError> {
2298    match value.trim().to_ascii_lowercase().as_str() {
2299        "claude" => Ok(Agent::Claude),
2300        "codex" => Ok(Agent::Codex),
2301        "pi" => Ok(Agent::Pi),
2302        _ => Err(ReviewerError::UnknownAgent {
2303            value: value.to_owned(),
2304        }),
2305    }
2306}
2307
2308fn harness_from_slug(value: &str) -> Result<ReviewerHarness, ReviewerError> {
2309    match value.trim().to_ascii_lowercase().as_str() {
2310        "claude" => Ok(ReviewerHarness::Claude),
2311        "codex" => Ok(ReviewerHarness::Codex),
2312        "pi" => Ok(ReviewerHarness::Pi),
2313        "gemini" => Ok(ReviewerHarness::Gemini),
2314        "opencode" => Ok(ReviewerHarness::Opencode),
2315        "custom" => Ok(ReviewerHarness::Custom),
2316        _ => Err(ReviewerError::UnknownHarness {
2317            value: value.to_owned(),
2318        }),
2319    }
2320}
2321
2322fn harness_slug(harness: ReviewerHarness) -> &'static str {
2323    match harness {
2324        ReviewerHarness::Claude => "claude",
2325        ReviewerHarness::Codex => "codex",
2326        ReviewerHarness::Pi => "pi",
2327        ReviewerHarness::Gemini => "gemini",
2328        ReviewerHarness::Opencode => "opencode",
2329        ReviewerHarness::Custom => "custom",
2330    }
2331}
2332
2333/// Normalise a model identifier for opposition comparisons.
2334///
2335/// Delegates to [`crate::config::normalized_model`], which is the single
2336/// authoritative implementation shared by config-time validation and the
2337/// runtime same-model guard here (R6 dedup).
2338fn normalized_model(model: &str) -> String {
2339    config::normalized_model(model)
2340}
2341
2342fn generate_run_id(commit_sha: &str) -> String {
2343    let nanos = SystemTime::now()
2344        .duration_since(UNIX_EPOCH)
2345        .map_or(0, |duration| duration.as_nanos());
2346    let short_sha: String = commit_sha
2347        .chars()
2348        .filter(|character| character.is_ascii_alphanumeric())
2349        .take(12)
2350        .collect();
2351    if short_sha.is_empty() {
2352        format!("{nanos}-{}", std::process::id())
2353    } else {
2354        format!("{nanos}-{}-{short_sha}", std::process::id())
2355    }
2356}
2357
2358#[cfg(test)]
2359mod tests {
2360    use std::{cell::RefCell, collections::VecDeque, fs, process::Command};
2361
2362    use super::normalized_model;
2363
2364    use proptest::prelude::*;
2365
2366    use super::{
2367        InvocationPlan, MaterialLoader, ParsedVerdict, ProcessOutput, ProcessRunner,
2368        PromptDelivery, ReviewJob, ReviewPlan, ReviewQueue, ReviewRequest, ReviewRun,
2369        ReviewRunStatus, ReviewRunStore, ReviewSelection, ReviewerError, StrictGoalCounters,
2370        StrictGoalDecision, StrictGoalPolicy, StrictGoalStopReason, StrictReviewConfig, drain_once,
2371        execute_review_job, is_full_git_sha, pid_is_alive, run_review_run_command,
2372        run_strict_goal_loop,
2373    };
2374    use crate::{
2375        claim::{Claim, EvidenceRef},
2376        cli::{Agent, ReviewerHarness},
2377        config::{Effort, TruthMirrorConfig},
2378        ledger::{LedgerStore, Verdict},
2379    };
2380
2381    fn pass_json() -> String {
2382        serde_json::json!({
2383            "verdict": "PASS",
2384            "summary": "The claim is substantiated by the diff and evidence.",
2385            "findings": [],
2386            "next_steps": [],
2387            "memory_skill": {
2388                "kind": "how_to_skill",
2389                "learning_source": "run reusable review workflow",
2390                "reasoning": "The passing claim describes a reusable verified procedure."
2391            }
2392        })
2393        .to_string()
2394    }
2395
2396    fn reject_json(title: &str) -> String {
2397        serde_json::json!({
2398            "verdict": "REJECT",
2399            "summary": "The claim is not substantiated.",
2400            "findings": [{
2401                "severity": "high",
2402                "title": title,
2403                "body": "The cited evidence does not prove the claimed behavior.",
2404                "file": "src/lib.rs",
2405                "line_start": 1,
2406                "line_end": 1,
2407                "confidence": 95,
2408                "recommendation": "Provide executable evidence that proves the claim."
2409            }],
2410            "next_steps": ["Run the relevant verification command."],
2411            "memory_skill": {
2412                "kind": "anti_pattern_skill",
2413                "learning_source": title,
2414                "reasoning": "The rejection identifies a reusable false-claim failure class."
2415            }
2416        })
2417        .to_string()
2418    }
2419
2420    #[test]
2421    fn same_harness_different_model_is_valid() {
2422        let request = ReviewRequest::new(
2423            Agent::Codex,
2424            "gpt-5.4",
2425            ReviewerHarness::Codex,
2426            "gpt-5.5",
2427            false,
2428            "review this",
2429        );
2430
2431        let plan = ReviewPlan::build(request).unwrap();
2432
2433        assert_eq!(plan.watched_agent, Agent::Codex);
2434        assert_eq!(plan.reviewer_harness, ReviewerHarness::Codex);
2435        assert_eq!(plan.invocation.program, "codex");
2436    }
2437
2438    #[test]
2439    fn same_model_is_blocked_by_default() {
2440        let request = ReviewRequest::new(
2441            Agent::Codex,
2442            " GPT-5.5 ",
2443            ReviewerHarness::Claude,
2444            "gpt-5.5",
2445            false,
2446            "review this",
2447        );
2448
2449        let error = ReviewPlan::build(request).unwrap_err();
2450
2451        assert!(matches!(
2452            error,
2453            ReviewerError::SameModelWithoutWaiver { .. }
2454        ));
2455    }
2456
2457    #[test]
2458    fn allow_same_model_override_is_deliberate() {
2459        let request = ReviewRequest::new(
2460            Agent::Codex,
2461            "gpt-5.5",
2462            ReviewerHarness::Codex,
2463            "gpt-5.5",
2464            true,
2465            "review this",
2466        );
2467
2468        let plan = ReviewPlan::build(request).unwrap();
2469
2470        assert!(plan.allow_same_model);
2471        assert_eq!(plan.reviewer_model, "gpt-5.5");
2472    }
2473
2474    #[test]
2475    fn provider_mapping_uses_verified_prompt_shapes_and_effort() {
2476        let codex =
2477            InvocationPlan::for_harness(ReviewerHarness::Codex, "gpt-5.5", Effort::Xhigh).unwrap();
2478        assert_eq!(codex.program, "codex");
2479        assert_eq!(
2480            codex.args_for_prompt("prompt"),
2481            [
2482                "exec",
2483                "-m",
2484                "gpt-5.5",
2485                "-c",
2486                "model_reasoning_effort=xhigh",
2487                "prompt"
2488            ]
2489        );
2490
2491        let claude =
2492            InvocationPlan::for_harness(ReviewerHarness::Claude, "opus", Effort::High).unwrap();
2493        assert_eq!(claude.program, "claude");
2494        assert_eq!(claude.prompt_delivery, PromptDelivery::Stdin);
2495        assert_eq!(
2496            claude.args_for_prompt("prompt"),
2497            ["--print", "--model", "opus", "--effort", "high"]
2498        );
2499
2500        let gemini =
2501            InvocationPlan::for_harness(ReviewerHarness::Gemini, "gemini-pro", Effort::Xhigh)
2502                .unwrap();
2503        assert_eq!(
2504            gemini.args_for_prompt("prompt"),
2505            ["-m", "gemini-pro", "-p", "prompt"]
2506        );
2507
2508        let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "openai/gpt-5.5", Effort::Xhigh)
2509            .unwrap();
2510        assert_eq!(pi.prompt_delivery, PromptDelivery::Stdin);
2511        assert_eq!(
2512            pi.args_for_prompt("prompt"),
2513            [
2514                "--model",
2515                "openai/gpt-5.5",
2516                "--thinking",
2517                "xhigh",
2518                "--tools",
2519                "read,grep,find,ls",
2520                "-p"
2521            ]
2522        );
2523    }
2524
2525    #[test]
2526    fn custom_harness_requires_explicit_configuration() {
2527        let error = InvocationPlan::for_harness(ReviewerHarness::Custom, "model", Effort::Xhigh)
2528            .unwrap_err();
2529
2530        assert!(matches!(error, ReviewerError::UnsupportedCustomHarness));
2531    }
2532
2533    #[test]
2534    fn effort_maps_to_each_harness_flag() {
2535        for effort in [
2536            Effort::Minimal,
2537            Effort::Low,
2538            Effort::Medium,
2539            Effort::High,
2540            Effort::Xhigh,
2541        ] {
2542            let e = effort.as_str();
2543
2544            let codex = InvocationPlan::for_harness(ReviewerHarness::Codex, "m", effort).unwrap();
2545            assert!(codex.args.contains(&format!("model_reasoning_effort={e}")));
2546
2547            let claude = InvocationPlan::for_harness(ReviewerHarness::Claude, "m", effort).unwrap();
2548            let claude_idx = claude.args.iter().position(|a| a == "--effort").unwrap();
2549            // Claude has no `minimal`; it clamps to a valid level (`low`).
2550            assert_eq!(claude.args[claude_idx + 1], effort.claude_value());
2551            assert_ne!(claude.args[claude_idx + 1], "minimal");
2552
2553            let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "m", effort).unwrap();
2554            let pi_idx = pi.args.iter().position(|a| a == "--thinking").unwrap();
2555            assert_eq!(pi.args[pi_idx + 1], e);
2556        }
2557    }
2558
2559    #[test]
2560    fn resolve_picks_configured_reviewer_for_every_writer() {
2561        let config = crate::config::TruthMirrorConfig::default();
2562
2563        let cases = [
2564            (Agent::Codex, ReviewerHarness::Claude, "claude-opus-4-8"),
2565            (Agent::Claude, ReviewerHarness::Codex, "gpt-5.5"),
2566            (Agent::Pi, ReviewerHarness::Codex, "gpt-5.5"),
2567        ];
2568
2569        for (writer, reviewer_harness, reviewer_model) in cases {
2570            let selection =
2571                ReviewSelection::resolve(Some(writer), None, None, None, None, false, &config)
2572                    .unwrap();
2573
2574            assert_eq!(selection.reviewer_harness, reviewer_harness);
2575            assert_eq!(selection.reviewer_model, reviewer_model);
2576            assert_eq!(selection.reviewer_effort, Effort::Xhigh);
2577        }
2578    }
2579
2580    #[test]
2581    fn overriding_reviewer_harness_without_model_is_rejected() {
2582        // codex's default pair reviewer is claude; overriding harness to pi with no
2583        // model would pair the pi harness with a claude model string.
2584        let config = crate::config::TruthMirrorConfig::default();
2585        let error = ReviewSelection::resolve(
2586            Some(Agent::Codex),
2587            None,
2588            Some(ReviewerHarness::Pi),
2589            None,
2590            None,
2591            false,
2592            &config,
2593        )
2594        .unwrap_err();
2595
2596        assert!(matches!(error, ReviewerError::OverrideNeedsModel { .. }));
2597    }
2598
2599    #[test]
2600    fn overriding_reviewer_harness_matching_pair_is_ok() {
2601        let config = crate::config::TruthMirrorConfig::default();
2602        let selection = ReviewSelection::resolve(
2603            Some(Agent::Codex),
2604            None,
2605            Some(ReviewerHarness::Claude),
2606            None,
2607            None,
2608            false,
2609            &config,
2610        )
2611        .unwrap();
2612
2613        assert_eq!(selection.reviewer_harness, ReviewerHarness::Claude);
2614        assert_eq!(selection.reviewer_model, "claude-opus-4-8");
2615    }
2616
2617    #[test]
2618    fn config_allow_same_model_waives_opposition() {
2619        let config = crate::config::TruthMirrorConfig {
2620            allow_same_model: true,
2621            ..crate::config::TruthMirrorConfig::default()
2622        };
2623
2624        let selection = ReviewSelection::resolve(
2625            Some(Agent::Codex),
2626            Some("gpt-5.5".to_owned()),
2627            Some(ReviewerHarness::Codex),
2628            Some("gpt-5.5".to_owned()),
2629            None,
2630            false, // CLI flag not set — the config waiver must carry it
2631            &config,
2632        )
2633        .unwrap();
2634
2635        assert!(selection.allow_same_model);
2636        // Same watched+reviewer model builds because the config waiver applies.
2637        assert!(ReviewPlan::build(selection.request_for("review".to_owned())).is_ok());
2638    }
2639
2640    #[test]
2641    fn full_git_sha_accepts_sha1_and_sha256_lengths() {
2642        assert!(is_full_git_sha(&"a".repeat(40)));
2643        assert!(is_full_git_sha(&"b".repeat(64)));
2644        assert!(!is_full_git_sha(&"c".repeat(39)));
2645        assert!(!is_full_git_sha(&"g".repeat(40)));
2646    }
2647
2648    #[test]
2649    fn resolve_arbiter_uses_pair_when_cli_absent() {
2650        let config = crate::config::TruthMirrorConfig::default();
2651        let arbiter =
2652            ReviewSelection::resolve_arbiter(Agent::Codex, None, None, None, &config).unwrap();
2653
2654        assert_eq!(arbiter.arbiter_harness, ReviewerHarness::Pi);
2655        assert_eq!(arbiter.arbiter_effort, Effort::Xhigh);
2656    }
2657
2658    #[test]
2659    fn first_pass_prompt_is_adversarial_and_injects_context() {
2660        let prompt = super::first_pass_prompt(
2661            &claim(),
2662            "THE_DIFF_BODY",
2663            "INVIOLABLE CONSTRAINTS: never fake tests",
2664        );
2665
2666        assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
2667        assert!(prompt.contains("default to REJECT"));
2668        assert!(prompt.contains("INVIOLABLE CONSTRAINTS: never fake tests"));
2669        assert!(prompt.contains("THE_DIFF_BODY"));
2670        // Class-generalized review: grep the class, not the instance.
2671        assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
2672        assert!(prompt.contains("\"severity\""));
2673        assert!(prompt.contains("\"recommendation\""));
2674        assert!(prompt.contains("\"memory_skill\""));
2675        assert!(prompt.contains("\"anti_pattern_skill\""));
2676    }
2677
2678    #[test]
2679    fn strict_second_pass_is_a_completeness_critic() {
2680        let job = review_job(true);
2681        let first_output = pass_json();
2682        let prompt = super::strict_second_pass_prompt(&job, &first_output);
2683
2684        assert!(prompt.contains("COMPLETENESS CRITIC"));
2685        assert!(prompt.contains("generalize"));
2686        // Inherits the class-sweep preamble.
2687        assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
2688    }
2689
2690    #[test]
2691    fn prompt_omits_context_block_when_empty() {
2692        let prompt = super::first_pass_prompt(&claim(), "d", "");
2693        // No dangling empty context header.
2694        assert!(!prompt.contains("INVIOLABLE CONSTRAINTS"));
2695        assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
2696    }
2697
2698    #[test]
2699    fn subprocess_runner_is_mockable() {
2700        struct MockRunner;
2701
2702        impl ProcessRunner for MockRunner {
2703            fn run(
2704                &self,
2705                invocation: &InvocationPlan,
2706                prompt: &str,
2707            ) -> Result<ProcessOutput, ReviewerError> {
2708                assert_eq!(invocation.program, "codex");
2709                assert_eq!(
2710                    invocation.args_for_prompt(prompt).last().unwrap(),
2711                    "review this"
2712                );
2713                Ok(ProcessOutput {
2714                    status_code: Some(0),
2715                    stdout: pass_json(),
2716                    stderr: String::new(),
2717                })
2718            }
2719        }
2720
2721        let request = ReviewRequest::new(
2722            Agent::Codex,
2723            "gpt-5.4",
2724            ReviewerHarness::Codex,
2725            "gpt-5.5",
2726            false,
2727            "review this",
2728        );
2729        let plan = ReviewPlan::build(request).unwrap();
2730        let output = plan.run_with("review this", &MockRunner).unwrap();
2731
2732        assert!(output.stdout.contains("PASS"));
2733    }
2734
2735    #[test]
2736    fn verdict_parser_extracts_rejection_findings() {
2737        let verdict = ParsedVerdict::parse(&reject_json("missing proof")).unwrap();
2738
2739        assert_eq!(verdict.verdict, Verdict::Reject);
2740        assert_eq!(verdict.structured_findings[0].title, "missing proof");
2741        assert_eq!(verdict.structured_findings[0].confidence, 95);
2742        assert!(verdict.findings[0].contains("missing proof"));
2743        assert_eq!(
2744            verdict.memory_skill_classification.learning_source,
2745            "missing proof"
2746        );
2747    }
2748
2749    #[test]
2750    fn verdict_parser_rejects_missing_memory_skill_classification() {
2751        let output = serde_json::json!({
2752            "verdict": "PASS",
2753            "summary": "The claim is substantiated.",
2754            "findings": [],
2755            "next_steps": []
2756        });
2757
2758        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
2759
2760        assert!(matches!(error, ReviewerError::VerdictJson { .. }));
2761    }
2762
2763    #[test]
2764    fn verdict_parser_rejects_incompatible_memory_skill_classification() {
2765        let mut output: serde_json::Value = serde_json::from_str(&pass_json()).unwrap();
2766        output["memory_skill"]["kind"] = serde_json::json!("remediation_skill");
2767
2768        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
2769
2770        assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
2771    }
2772
2773    #[test]
2774    fn verdict_parser_accepts_normalized_float_confidence() {
2775        let mut output: serde_json::Value =
2776            serde_json::from_str(&reject_json("missing proof")).unwrap();
2777        output["findings"][0]["confidence"] = serde_json::json!(0.95);
2778
2779        let verdict = ParsedVerdict::parse(&output.to_string()).unwrap();
2780
2781        assert_eq!(verdict.structured_findings[0].confidence, 95);
2782    }
2783
2784    #[test]
2785    fn verdict_parser_rejects_legacy_line_protocol() {
2786        let error =
2787            ParsedVerdict::parse("VERDICT: REJECT\nFINDINGS:\n- missing proof\n").unwrap_err();
2788
2789        assert!(matches!(error, ReviewerError::VerdictJson { .. }));
2790    }
2791
2792    #[test]
2793    fn large_diff_materialization_falls_back_to_file_summary() {
2794        let files = "a.rs\nb.rs\nc.rs\n";
2795        let materialized = super::materialize_diff("branch:main", "tiny diff", files);
2796
2797        assert!(materialized.contains("too large to inline safely"));
2798        assert!(materialized.contains("actual_files=3"));
2799        assert!(materialized.contains("a.rs\nb.rs\nc.rs"));
2800        assert!(materialized.contains("inspect the repository directly"));
2801    }
2802
2803    #[test]
2804    fn review_queue_schedules_commits_without_running_models() {
2805        let temp = tempfile::tempdir().unwrap();
2806        let queue = ReviewQueue::new(temp.path());
2807
2808        queue.enqueue("abc123").unwrap();
2809
2810        let pending = queue.pending().unwrap();
2811        assert_eq!(pending.len(), 1);
2812        assert_eq!(pending[0].commit_sha, "abc123");
2813        assert!(!pending[0].run_id.is_empty());
2814
2815        let run = ReviewRunStore::new(temp.path())
2816            .read(&pending[0].run_id)
2817            .unwrap();
2818        assert_eq!(run.commit_sha, "abc123");
2819        assert_eq!(run.status, ReviewRunStatus::Queued);
2820        assert_eq!(run.entire_checkpoint, None);
2821    }
2822
2823    #[test]
2824    fn review_queue_summary_counts_pending_and_oldest() {
2825        let temp = tempfile::tempdir().unwrap();
2826        let queue = ReviewQueue::new(temp.path());
2827        std::fs::write(
2828            queue.path(),
2829            "{\"commit_sha\":\"abc123\",\"enqueued_at_unix\":30}\n{\"commit_sha\":\"def456\",\"enqueued_at_unix\":10}\n",
2830        )
2831        .unwrap();
2832
2833        let summary = queue.summary().unwrap();
2834
2835        assert_eq!(summary.pending_count, 2);
2836        assert_eq!(summary.oldest_enqueued_at_unix, Some(10));
2837        assert_eq!(summary.oldest_age_secs_at(42), Some(32));
2838    }
2839
2840    #[test]
2841    fn review_run_serializes_optional_entire_checkpoint() {
2842        let run = ReviewRun::queued_with_provenance(
2843            "run-id",
2844            "abcdef123456",
2845            "commit",
2846            Some(crate::provenance::EntireCheckpointRef {
2847                ref_name: "entire/session-abcdef".to_owned(),
2848                object_sha: "123456".to_owned(),
2849            }),
2850        );
2851
2852        let json = serde_json::to_string(&run).unwrap();
2853
2854        assert!(json.contains("entire_checkpoint"));
2855        assert!(json.contains("entire/session-abcdef"));
2856    }
2857
2858    #[test]
2859    fn review_run_omits_absent_optional_entire_checkpoint() {
2860        let run = ReviewRun::queued("run-id", "abcdef123456", "commit");
2861
2862        let json = serde_json::to_string(&run).unwrap();
2863
2864        assert!(!json.contains("entire_checkpoint"));
2865    }
2866
2867    #[test]
2868    fn review_run_status_counts_read_only_status_fields() {
2869        let temp = tempfile::tempdir().unwrap();
2870        let store = ReviewRunStore::new(temp.path());
2871        let queued = store.create_queued("abc123", "commit").unwrap();
2872        let mut failed = ReviewRun::queued("failed-run", "def456", "commit");
2873        failed.mark_failed("boom");
2874        store.write(&failed).unwrap();
2875        fs::write(store.path("malformed-run"), "{not-json\n").unwrap();
2876
2877        let counts = store.status_counts().unwrap();
2878
2879        assert_eq!(counts.queued, 1);
2880        assert_eq!(counts.failed, 1);
2881        assert_eq!(counts.running, 0);
2882        assert_eq!(counts.skipped_records, 1);
2883        assert_eq!(queued.status, ReviewRunStatus::Queued);
2884    }
2885
2886    #[test]
2887    fn review_cancel_marks_queued_run_and_removes_queue_item() {
2888        let temp = tempfile::tempdir().unwrap();
2889        let queue = ReviewQueue::new(temp.path());
2890        let queued = queue.enqueue("abc123").unwrap();
2891
2892        run_review_run_command(
2893            crate::cli::ReviewCommand::Cancel {
2894                run_id: queued.run_id.clone(),
2895                force: false,
2896            },
2897            temp.path(),
2898        )
2899        .unwrap();
2900
2901        assert!(queue.pending().unwrap().is_empty());
2902        let run = ReviewRunStore::new(temp.path())
2903            .read(&queued.run_id)
2904            .unwrap();
2905        assert_eq!(run.status, ReviewRunStatus::Cancelled);
2906    }
2907
2908    /// A pid guaranteed to be dead: spawn a trivial process, reap it, return its pid.
2909    fn reaped_pid() -> u32 {
2910        let mut child = Command::new("true").spawn().expect("spawn `true`");
2911        let pid = child.id();
2912        child.wait().expect("reap `true`");
2913        pid
2914    }
2915
2916    /// Persist a run pinned to `Running` with a specific worker pid, bypassing the
2917    /// normal lifecycle so liveness handling can be exercised deterministically.
2918    fn write_running_run(store: &ReviewRunStore, worker_pid: Option<u32>) -> ReviewRun {
2919        let mut run = store.create_queued("abc123", "commit").unwrap();
2920        run.status = ReviewRunStatus::Running;
2921        run.phase = "reviewing".to_owned();
2922        run.worker_pid = worker_pid;
2923        store.write(&run).unwrap();
2924        run
2925    }
2926
2927    #[test]
2928    fn pid_liveness_probe_tracks_real_processes() {
2929        assert!(pid_is_alive(std::process::id()));
2930        assert!(!pid_is_alive(reaped_pid()));
2931    }
2932
2933    #[test]
2934    fn reconcile_liveness_only_reaps_dead_running_runs() {
2935        let mut queued = ReviewRun::queued("id", "abc123", "commit");
2936        // A queued run is never touched, even if the predicate reports dead.
2937        assert!(!queued.reconcile_liveness(|_| false));
2938        assert_eq!(queued.status, ReviewRunStatus::Queued);
2939
2940        queued.mark_running("reviewing");
2941        // Running + alive stays running.
2942        assert!(!queued.reconcile_liveness(|_| true));
2943        assert_eq!(queued.status, ReviewRunStatus::Running);
2944        // Running + dead flips to failed with a stale-worker reason.
2945        assert!(queued.reconcile_liveness(|_| false));
2946        assert_eq!(queued.status, ReviewRunStatus::Failed);
2947        assert!(queued.error.as_deref().unwrap().contains("stale run"));
2948        assert!(queued.worker_pid.is_none());
2949
2950        // A legacy running run with no recorded pid is left alone.
2951        let mut legacy = ReviewRun::queued("id2", "def456", "commit");
2952        legacy.status = ReviewRunStatus::Running;
2953        legacy.worker_pid = None;
2954        assert!(!legacy.reconcile_liveness(|_| false));
2955        assert_eq!(legacy.status, ReviewRunStatus::Running);
2956    }
2957
2958    #[test]
2959    fn review_status_reaps_running_run_with_dead_worker_and_persists() {
2960        let temp = tempfile::tempdir().unwrap();
2961        let store = ReviewRunStore::new(temp.path());
2962        let run = write_running_run(&store, Some(reaped_pid()));
2963
2964        let reconciled = store.read_reconciled(&run.id).unwrap();
2965        assert_eq!(reconciled.status, ReviewRunStatus::Failed);
2966        assert!(reconciled.error.as_deref().unwrap().contains("stale run"));
2967
2968        // The correction is persisted, not just displayed.
2969        assert_eq!(store.read(&run.id).unwrap().status, ReviewRunStatus::Failed);
2970        // list_reconciled agrees.
2971        let listed = store.list_reconciled().unwrap();
2972        assert_eq!(listed.len(), 1);
2973        assert_eq!(listed[0].status, ReviewRunStatus::Failed);
2974    }
2975
2976    #[test]
2977    fn review_status_leaves_running_run_with_live_worker() {
2978        let temp = tempfile::tempdir().unwrap();
2979        let store = ReviewRunStore::new(temp.path());
2980        let run = write_running_run(&store, Some(std::process::id()));
2981
2982        let reconciled = store.read_reconciled(&run.id).unwrap();
2983        assert_eq!(reconciled.status, ReviewRunStatus::Running);
2984    }
2985
2986    #[test]
2987    fn cancel_reaps_running_run_with_dead_worker_without_force() {
2988        let temp = tempfile::tempdir().unwrap();
2989        let store = ReviewRunStore::new(temp.path());
2990        let run = write_running_run(&store, Some(reaped_pid()));
2991
2992        let cancelled = store.cancel(&run.id, false).unwrap();
2993        assert_eq!(cancelled.status, ReviewRunStatus::Failed);
2994        assert!(cancelled.error.as_deref().unwrap().contains("stale run"));
2995    }
2996
2997    #[test]
2998    fn cancel_refuses_live_running_run_without_force() {
2999        let temp = tempfile::tempdir().unwrap();
3000        let store = ReviewRunStore::new(temp.path());
3001        let run = write_running_run(&store, Some(std::process::id()));
3002
3003        let error = store.cancel(&run.id, false).unwrap_err();
3004        assert!(matches!(error, ReviewerError::ReviewRunStillAlive { .. }));
3005        // The run is untouched.
3006        assert_eq!(
3007            store.read(&run.id).unwrap().status,
3008            ReviewRunStatus::Running
3009        );
3010    }
3011
3012    #[test]
3013    fn cancel_force_kills_live_worker_and_cancels() {
3014        let temp = tempfile::tempdir().unwrap();
3015        let store = ReviewRunStore::new(temp.path());
3016        let mut child = Command::new("sleep")
3017            .arg("30")
3018            .spawn()
3019            .expect("spawn sleep");
3020        let pid = child.id();
3021        let run = write_running_run(&store, Some(pid));
3022
3023        let cancelled = store.cancel(&run.id, true).unwrap();
3024        assert_eq!(cancelled.status, ReviewRunStatus::Cancelled);
3025
3026        // Reap the killed child, then confirm it is truly gone.
3027        let _ = child.wait();
3028        assert!(!pid_is_alive(pid));
3029    }
3030
3031    #[test]
3032    fn cancel_legacy_running_run_requires_force_then_reaps() {
3033        let temp = tempfile::tempdir().unwrap();
3034        let store = ReviewRunStore::new(temp.path());
3035        let run = write_running_run(&store, None);
3036
3037        let error = store.cancel(&run.id, false).unwrap_err();
3038        assert!(matches!(
3039            error,
3040            ReviewerError::ReviewRunLivenessUnknown { .. }
3041        ));
3042
3043        let reaped = store.cancel(&run.id, true).unwrap();
3044        assert_eq!(reaped.status, ReviewRunStatus::Failed);
3045    }
3046
3047    #[test]
3048    fn cancel_refuses_already_terminal_run() {
3049        let temp = tempfile::tempdir().unwrap();
3050        let store = ReviewRunStore::new(temp.path());
3051        let run = store.create_queued("abc123", "commit").unwrap();
3052        store.mark_completed(&run.id, 0).unwrap();
3053
3054        let error = store.cancel(&run.id, true).unwrap_err();
3055        assert!(matches!(
3056            error,
3057            ReviewerError::CannotCancelReview {
3058                status: ReviewRunStatus::Completed,
3059                ..
3060            }
3061        ));
3062    }
3063
3064    #[test]
3065    fn execute_review_records_reject_verdict() {
3066        let temp = tempfile::tempdir().unwrap();
3067        let store = LedgerStore::new(temp.path());
3068        let job = review_job(false);
3069        let runner = SequenceRunner::new([reject_json("unsupported")]);
3070
3071        let execution = execute_review_job(job, &runner, &store).unwrap();
3072
3073        assert_eq!(execution.entries.len(), 1);
3074        assert_eq!(execution.entries[0].verdict, Verdict::Reject);
3075        assert_eq!(
3076            execution.entries[0].structured_findings[0].title,
3077            "unsupported"
3078        );
3079        assert!(
3080            execution.entries[0]
3081                .raw_reviewer_output
3082                .contains("\"REJECT\"")
3083        );
3084        assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3085    }
3086
3087    #[test]
3088    fn strict_two_pass_records_both_clean_passes() {
3089        let temp = tempfile::tempdir().unwrap();
3090        let store = LedgerStore::new(temp.path());
3091        let job = review_job(true);
3092        let runner = SequenceRunner::new([pass_json(), pass_json()]);
3093
3094        let execution = execute_review_job(job, &runner, &store).unwrap();
3095
3096        assert_eq!(execution.entries.len(), 2);
3097        assert_eq!(store.read_history().unwrap().len(), 2);
3098        assert_eq!(execution.entries[0].reviewer.model, "gpt-5.5");
3099        assert_eq!(execution.entries[1].reviewer.model, "claude-opus-4-8");
3100    }
3101
3102    #[test]
3103    fn strict_arbiter_model_must_be_third_model() {
3104        let temp = tempfile::tempdir().unwrap();
3105        let store = LedgerStore::new(temp.path());
3106        let mut job = review_job(true);
3107        job.strict.as_mut().unwrap().arbiter_model = "gpt-5.5".to_owned();
3108        let runner = SequenceRunner::new([pass_json()]);
3109
3110        let error = execute_review_job(job, &runner, &store).unwrap_err();
3111
3112        assert!(matches!(
3113            error,
3114            ReviewerError::StrictArbiterModelNotDistinct
3115        ));
3116    }
3117
3118    #[test]
3119    fn strict_goal_policy_stops_at_configured_lie_or_fuckup_count() {
3120        let policy = StrictGoalPolicy {
3121            stop_after_lies: 2,
3122            stop_after_fuckups: 3,
3123        };
3124
3125        assert_eq!(
3126            policy.decide(StrictGoalCounters {
3127                lies_exposed: 1,
3128                fuckups_registered: 2
3129            }),
3130            StrictGoalDecision::Continue
3131        );
3132        assert_eq!(
3133            policy.decide(StrictGoalCounters {
3134                lies_exposed: 2,
3135                fuckups_registered: 0
3136            }),
3137            StrictGoalDecision::Stop {
3138                reason: StrictGoalStopReason::LiesExposed
3139            }
3140        );
3141        assert_eq!(
3142            policy.decide(StrictGoalCounters {
3143                lies_exposed: 0,
3144                fuckups_registered: 3
3145            }),
3146            StrictGoalDecision::Stop {
3147                reason: StrictGoalStopReason::FuckupsRegistered
3148            }
3149        );
3150    }
3151
3152    #[test]
3153    fn drain_once_reviews_each_commit_once_and_clears_queue() {
3154        let temp = tempfile::tempdir().unwrap();
3155        let store = LedgerStore::new(temp.path());
3156        let queue = ReviewQueue::new(temp.path());
3157        queue.enqueue("abc123").unwrap();
3158        queue.enqueue("abc123").unwrap(); // duplicate SHA reviewed only once
3159        queue.enqueue("def456").unwrap();
3160
3161        let loader = StaticLoader::new();
3162        let runner = SequenceRunner::new([reject_json("unsupported"), pass_json()]);
3163        let selection = selection();
3164
3165        let report = drain_once(
3166            &queue,
3167            &loader,
3168            &selection,
3169            "",
3170            &runner,
3171            &store,
3172            &TruthMirrorConfig::default(),
3173        )
3174        .unwrap();
3175
3176        assert_eq!(report.reviewed, ["abc123", "def456"]);
3177        assert_eq!(report.ledger_entries, 2);
3178        assert!(queue.pending().unwrap().is_empty());
3179        assert_eq!(store.read_history().unwrap().len(), 2);
3180        assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3181
3182        let runs = ReviewRunStore::new(temp.path()).list().unwrap();
3183        assert_eq!(runs.len(), 3);
3184        assert_eq!(
3185            runs.iter()
3186                .filter(|run| run.status == ReviewRunStatus::Completed)
3187                .count(),
3188            2
3189        );
3190        assert_eq!(
3191            runs.iter()
3192                .filter(|run| run.status == ReviewRunStatus::Cancelled)
3193                .count(),
3194            1
3195        );
3196    }
3197
3198    #[test]
3199    fn drain_once_completes_when_memory_skill_extraction_fails() {
3200        let temp = tempfile::tempdir().unwrap();
3201        let store = LedgerStore::new(temp.path());
3202        let queue = ReviewQueue::new(temp.path());
3203        let sha = "a".repeat(40);
3204        queue.enqueue(&sha).unwrap();
3205        let loader = StaticLoader::new();
3206        let runner = ConstRunner::new(pass_json());
3207        let mut config = TruthMirrorConfig::default();
3208        config.skills.enabled = true;
3209        config.memory_skill.enabled = true;
3210        config.memory_skill.signals.min_occurrences = 1;
3211        config.memory_skill.scan.blocked_patterns = vec!["Capture a verified procedure".to_owned()];
3212
3213        let report =
3214            drain_once(&queue, &loader, &selection(), "", &runner, &store, &config).unwrap();
3215
3216        assert_eq!(report.reviewed, [sha]);
3217        assert_eq!(report.ledger_entries, 1);
3218        assert!(queue.pending().unwrap().is_empty());
3219        assert_eq!(store.read_history().unwrap().len(), 1);
3220        let candidate_dir =
3221            crate::memory_skill::MemorySkillStore::new(temp.path(), &config.memory_skill)
3222                .unwrap()
3223                .candidate_dir()
3224                .to_path_buf();
3225        assert!(!candidate_dir.exists());
3226    }
3227
3228    #[test]
3229    fn drain_once_is_a_noop_on_empty_queue() {
3230        let temp = tempfile::tempdir().unwrap();
3231        let store = LedgerStore::new(temp.path());
3232        let queue = ReviewQueue::new(temp.path());
3233        let loader = StaticLoader::new();
3234        let runner = ConstRunner::new(pass_json());
3235
3236        let report = drain_once(
3237            &queue,
3238            &loader,
3239            &selection(),
3240            "",
3241            &runner,
3242            &store,
3243            &TruthMirrorConfig::default(),
3244        )
3245        .unwrap();
3246
3247        assert!(report.reviewed.is_empty());
3248        assert_eq!(report.ledger_entries, 0);
3249        assert_eq!(store.read_history().unwrap().len(), 0);
3250    }
3251
3252    #[test]
3253    fn strict_goal_loop_stops_at_configured_lie_count() {
3254        let temp = tempfile::tempdir().unwrap();
3255        let store = LedgerStore::new(temp.path());
3256        let policy = StrictGoalPolicy {
3257            stop_after_lies: 1,
3258            stop_after_fuckups: 0,
3259        };
3260        let runner = SequenceRunner::new([reject_json("lie")]);
3261
3262        let outcome = run_strict_goal_loop(
3263            "abc123",
3264            &claim(),
3265            "diff",
3266            "",
3267            &selection(),
3268            policy,
3269            5,
3270            &runner,
3271            &store,
3272        )
3273        .unwrap();
3274
3275        assert_eq!(outcome.passes, 1);
3276        assert_eq!(outcome.counters.lies_exposed, 1);
3277        assert_eq!(outcome.stop_reason, Some(StrictGoalStopReason::LiesExposed));
3278        assert_eq!(store.read_history().unwrap().len(), 1);
3279    }
3280
3281    #[test]
3282    fn strict_goal_loop_terminates_at_max_passes_for_honest_agent() {
3283        let temp = tempfile::tempdir().unwrap();
3284        let store = LedgerStore::new(temp.path());
3285        let policy = StrictGoalPolicy {
3286            stop_after_lies: 2,
3287            stop_after_fuckups: 5,
3288        };
3289        let runner = ConstRunner::new(pass_json());
3290
3291        let outcome = run_strict_goal_loop(
3292            "abc123",
3293            &claim(),
3294            "diff",
3295            "",
3296            &selection(),
3297            policy,
3298            3,
3299            &runner,
3300            &store,
3301        )
3302        .unwrap();
3303
3304        assert_eq!(outcome.passes, 3);
3305        assert_eq!(outcome.counters.lies_exposed, 0);
3306        assert_eq!(outcome.stop_reason, None);
3307        assert_eq!(store.read_history().unwrap().len(), 3);
3308    }
3309
3310    #[test]
3311    fn strict_goal_loop_stops_when_fuckups_accumulate() {
3312        let temp = tempfile::tempdir().unwrap();
3313        let store = LedgerStore::new(temp.path());
3314        let policy = StrictGoalPolicy {
3315            stop_after_lies: 0,
3316            stop_after_fuckups: 2,
3317        };
3318        // Each structured finding registers one fuckup; two passes hit N=2.
3319        let runner = ConstRunner::new(reject_json("nit"));
3320
3321        let outcome = run_strict_goal_loop(
3322            "abc123",
3323            &claim(),
3324            "diff",
3325            "",
3326            &selection(),
3327            policy,
3328            10,
3329            &runner,
3330            &store,
3331        )
3332        .unwrap();
3333
3334        assert_eq!(outcome.passes, 2);
3335        assert_eq!(outcome.counters.lies_exposed, 2);
3336        assert_eq!(outcome.counters.fuckups_registered, 2);
3337        assert_eq!(
3338            outcome.stop_reason,
3339            Some(StrictGoalStopReason::FuckupsRegistered)
3340        );
3341    }
3342
3343    proptest! {
3344        #[test]
3345        fn strict_goal_loop_never_exceeds_max_passes(max in 1u32..6) {
3346            let temp = tempfile::tempdir().unwrap();
3347            let store = LedgerStore::new(temp.path());
3348            // Both thresholds disabled: only the ceiling can stop the loop.
3349            let policy = StrictGoalPolicy { stop_after_lies: 0, stop_after_fuckups: 0 };
3350            let runner = ConstRunner::new(pass_json());
3351
3352            let outcome = run_strict_goal_loop(
3353                "abc123", &claim(), "diff", "", &selection(), policy, max, &runner, &store,
3354            )
3355            .unwrap();
3356
3357            prop_assert!(outcome.passes <= max);
3358            prop_assert_eq!(outcome.passes, max);
3359            prop_assert!(outcome.stop_reason.is_none());
3360        }
3361    }
3362
3363    proptest! {
3364        #[test]
3365        fn model_opposition_is_enforced_for_arbitrary_models(
3366            watched in "[A-Za-z0-9._/-]{1,32}",
3367            reviewer in "[A-Za-z0-9._/-]{1,32}",
3368        ) {
3369            let request = ReviewRequest::new(
3370                Agent::Codex,
3371                watched.clone(),
3372                ReviewerHarness::Codex,
3373                reviewer.clone(),
3374                false,
3375                "review this",
3376            );
3377            let result = ReviewPlan::build(request);
3378
3379            // Use the same normalisation that ReviewPlan::build uses so that
3380            // provider-prefixed models (e.g. "openai-codex/gpt-5.5" vs "gpt-5.5")
3381            // are treated as the same model after the B2 fix.
3382            if normalized_model(watched.trim()) == normalized_model(reviewer.trim()) {
3383                let blocked = matches!(result, Err(ReviewerError::SameModelWithoutWaiver { .. }));
3384                prop_assert!(blocked);
3385            } else {
3386                prop_assert!(result.is_ok());
3387            }
3388        }
3389    }
3390
3391    fn claim() -> Claim {
3392        Claim::new(
3393            "add review",
3394            "cargo test",
3395            vec![EvidenceRef::parse("tests:cargo-test").unwrap()],
3396        )
3397        .unwrap()
3398    }
3399
3400    fn selection() -> ReviewSelection {
3401        ReviewSelection {
3402            watched_agent: Agent::Codex,
3403            watched_model: "gpt-5.4".to_owned(),
3404            reviewer_harness: ReviewerHarness::Codex,
3405            reviewer_model: "gpt-5.5".to_owned(),
3406            reviewer_effort: Effort::Xhigh,
3407            allow_same_model: false,
3408            strict: None,
3409        }
3410    }
3411
3412    struct StaticLoader {
3413        claim: Claim,
3414        diff: String,
3415    }
3416
3417    impl StaticLoader {
3418        fn new() -> Self {
3419            Self {
3420                claim: claim(),
3421                diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
3422            }
3423        }
3424    }
3425
3426    impl MaterialLoader for StaticLoader {
3427        fn load(&self, _sha: &str) -> Result<(Claim, String), ReviewerError> {
3428            Ok((self.claim.clone(), self.diff.clone()))
3429        }
3430    }
3431
3432    struct ConstRunner {
3433        output: String,
3434    }
3435
3436    impl ConstRunner {
3437        fn new(output: impl Into<String>) -> Self {
3438            Self {
3439                output: output.into(),
3440            }
3441        }
3442    }
3443
3444    impl ProcessRunner for ConstRunner {
3445        fn run(
3446            &self,
3447            _invocation: &InvocationPlan,
3448            _prompt: &str,
3449        ) -> Result<ProcessOutput, ReviewerError> {
3450            Ok(ProcessOutput {
3451                status_code: Some(0),
3452                stdout: self.output.clone(),
3453                stderr: String::new(),
3454            })
3455        }
3456    }
3457
3458    fn review_job(strict: bool) -> ReviewJob {
3459        let claim = claim();
3460        ReviewJob {
3461            commit_sha: "abc123".to_owned(),
3462            diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
3463            context: String::new(),
3464            request: ReviewRequest::new(
3465                Agent::Codex,
3466                "gpt-5.4",
3467                ReviewerHarness::Codex,
3468                "gpt-5.5",
3469                false,
3470                "review this",
3471            ),
3472            claim,
3473            strict: strict.then_some(StrictReviewConfig {
3474                arbiter_harness: ReviewerHarness::Claude,
3475                arbiter_model: "claude-opus-4-8".to_owned(),
3476                arbiter_effort: Effort::Xhigh,
3477            }),
3478        }
3479    }
3480
3481    struct SequenceRunner {
3482        outputs: RefCell<VecDeque<String>>,
3483    }
3484
3485    impl SequenceRunner {
3486        fn new<I, S>(outputs: I) -> Self
3487        where
3488            I: IntoIterator<Item = S>,
3489            S: Into<String>,
3490        {
3491            Self {
3492                outputs: RefCell::new(outputs.into_iter().map(Into::into).collect()),
3493            }
3494        }
3495    }
3496
3497    impl ProcessRunner for SequenceRunner {
3498        fn run(
3499            &self,
3500            _invocation: &InvocationPlan,
3501            _prompt: &str,
3502        ) -> Result<ProcessOutput, ReviewerError> {
3503            let stdout = self.outputs.borrow_mut().pop_front().unwrap();
3504            Ok(ProcessOutput {
3505                status_code: Some(0),
3506                stdout,
3507                stderr: String::new(),
3508            })
3509        }
3510    }
3511}