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