Skip to main content

truth_mirror/
reviewer.rs

1//! Reviewer process harness, model opposition, async queue, and verdict execution.
2
3use std::{
4    fs,
5    io::{self, Write},
6    path::{Path, PathBuf},
7    process::{Command, ExitCode, Stdio},
8    time::{SystemTime, UNIX_EPOCH},
9};
10
11use anyhow::Result;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::{
16    claim::{Claim, EvidenceRef},
17    cli::{self, Agent, ReviewScope, ReviewerHarness},
18    config::{self, Effort},
19    ledger::{
20        LedgerEntry, LedgerStore, MemorySkillClassification, ReviewerConfig, StructuredFinding,
21        Verdict,
22    },
23    provenance::{self, EntireCheckpointRef},
24    surface,
25    time::unix_now,
26};
27
28pub const REVIEW_QUEUE_FILE: &str = "review-queue.jsonl";
29pub const REVIEW_RUNS_DIR: &str = "runs";
30const MAX_INLINE_DIFF_FILES: usize = 2;
31const MAX_INLINE_DIFF_BYTES: usize = 256 * 1024;
32const MAX_UNTRACKED_FILE_BYTES: u64 = 16 * 1024;
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct ReviewRequest {
36    pub watched_agent: Agent,
37    pub watched_model: String,
38    pub reviewer_harness: ReviewerHarness,
39    pub reviewer_model: String,
40    pub reviewer_effort: Effort,
41    pub allow_same_model: bool,
42    pub prompt: String,
43}
44
45impl ReviewRequest {
46    pub fn new(
47        watched_agent: Agent,
48        watched_model: impl Into<String>,
49        reviewer_harness: ReviewerHarness,
50        reviewer_model: impl Into<String>,
51        allow_same_model: bool,
52        prompt: impl Into<String>,
53    ) -> Self {
54        Self {
55            watched_agent,
56            watched_model: watched_model.into(),
57            reviewer_harness,
58            reviewer_model: reviewer_model.into(),
59            reviewer_effort: Effort::highest(),
60            allow_same_model,
61            prompt: prompt.into(),
62        }
63    }
64
65    pub fn with_effort(mut self, effort: Effort) -> Self {
66        self.reviewer_effort = effort;
67        self
68    }
69}
70
71/// Resolved reviewer selection shared by `review` and `watch`. The reviewer is
72/// chosen from the writer harness's adversarial pair; explicit CLI values win.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct ReviewSelection {
75    pub watched_agent: Agent,
76    pub watched_model: String,
77    pub reviewer_harness: ReviewerHarness,
78    pub reviewer_model: String,
79    pub reviewer_effort: Effort,
80    pub allow_same_model: bool,
81    pub strict: Option<StrictReviewConfig>,
82}
83
84impl ReviewSelection {
85    /// Resolve the reviewer for a writer harness from its adversarial pair,
86    /// applying any explicit CLI overrides. `strict` is set separately.
87    #[allow(clippy::too_many_arguments)]
88    pub fn resolve(
89        watched_agent: Option<Agent>,
90        watched_model: Option<String>,
91        reviewer_harness: Option<ReviewerHarness>,
92        reviewer_model: Option<String>,
93        reviewer_effort: Option<Effort>,
94        allow_same_model: bool,
95        config: &config::TruthMirrorConfig,
96    ) -> Result<Self, ReviewerError> {
97        let watched_agent = match watched_agent {
98            Some(agent) => agent,
99            None => agent_from_slug(&config.default_writer)?,
100        };
101        let writer_slug = surface::agent_slug(watched_agent);
102        let pair = config.pair_for(writer_slug);
103
104        let harness_from_cli = reviewer_harness.is_some();
105        let reviewer_harness = match reviewer_harness {
106            Some(harness) => harness,
107            None => {
108                let slug = pair
109                    .map(|pair| pair.reviewer.harness.as_str())
110                    .ok_or_else(|| ReviewerError::NoPairForWriter {
111                        writer: writer_slug.to_owned(),
112                    })?;
113                harness_from_slug(slug)?
114            }
115        };
116        let reviewer_model = match reviewer_model {
117            Some(model) => model,
118            None => {
119                let pair = pair.ok_or_else(|| ReviewerError::NoPairForWriter {
120                    writer: writer_slug.to_owned(),
121                })?;
122                // Don't pair a CLI-overridden harness with a model string meant for
123                // a different harness — that silently reviews with the wrong tool.
124                if harness_from_cli
125                    && !pair
126                        .reviewer
127                        .harness
128                        .eq_ignore_ascii_case(harness_slug(reviewer_harness))
129                {
130                    return Err(ReviewerError::OverrideNeedsModel {
131                        role: "reviewer".to_owned(),
132                        harness: harness_slug(reviewer_harness).to_owned(),
133                    });
134                }
135                pair.reviewer.model.clone()
136            }
137        };
138        let reviewer_effort = reviewer_effort
139            .or_else(|| pair.map(|pair| pair.reviewer.effort))
140            .unwrap_or_else(Effort::highest);
141
142        Ok(Self {
143            watched_agent,
144            watched_model: watched_model.unwrap_or_default(),
145            reviewer_harness,
146            reviewer_model,
147            reviewer_effort,
148            // Either the CLI flag or config may waive model opposition.
149            allow_same_model: allow_same_model || config.allow_same_model,
150            strict: None,
151        })
152    }
153
154    /// Resolve the second-pass arbiter for the writer, preferring CLI overrides,
155    /// then the writer pair's `arbiter`.
156    pub fn resolve_arbiter(
157        watched_agent: Agent,
158        arbiter_harness: Option<ReviewerHarness>,
159        arbiter_model: Option<String>,
160        arbiter_effort: Option<Effort>,
161        config: &config::TruthMirrorConfig,
162    ) -> Result<StrictReviewConfig, ReviewerError> {
163        let pair_arbiter = config
164            .pair_for(surface::agent_slug(watched_agent))
165            .and_then(|pair| pair.arbiter.clone());
166
167        let harness_from_cli = arbiter_harness.is_some();
168        let harness = match arbiter_harness {
169            Some(harness) => harness,
170            None => {
171                let slug = pair_arbiter
172                    .as_ref()
173                    .map(|arbiter| arbiter.harness.as_str())
174                    .ok_or(ReviewerError::MissingArbiter)?;
175                harness_from_slug(slug)?
176            }
177        };
178        let model = match arbiter_model {
179            Some(model) => model,
180            None => {
181                let arbiter = pair_arbiter.as_ref().ok_or(ReviewerError::MissingArbiter)?;
182                if harness_from_cli && !arbiter.harness.eq_ignore_ascii_case(harness_slug(harness))
183                {
184                    return Err(ReviewerError::OverrideNeedsModel {
185                        role: "arbiter".to_owned(),
186                        harness: harness_slug(harness).to_owned(),
187                    });
188                }
189                arbiter.model.clone()
190            }
191        };
192        let effort = arbiter_effort
193            .or_else(|| pair_arbiter.as_ref().map(|arbiter| arbiter.effort))
194            .unwrap_or_else(Effort::highest);
195
196        Ok(StrictReviewConfig {
197            arbiter_harness: harness,
198            arbiter_model: model,
199            arbiter_effort: effort,
200        })
201    }
202
203    fn request_for(&self, prompt: String) -> ReviewRequest {
204        ReviewRequest::new(
205            self.watched_agent,
206            self.watched_model.clone(),
207            self.reviewer_harness,
208            self.reviewer_model.clone(),
209            self.allow_same_model,
210            prompt,
211        )
212        .with_effort(self.reviewer_effort)
213    }
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
217pub struct ReviewPlan {
218    pub watched_agent: Agent,
219    pub watched_model: String,
220    pub reviewer_harness: ReviewerHarness,
221    pub reviewer_model: String,
222    pub allow_same_model: bool,
223    pub invocation: InvocationPlan,
224}
225
226impl ReviewPlan {
227    pub fn build(request: ReviewRequest) -> Result<Self, ReviewerError> {
228        validate_model_present("reviewer", &request.reviewer_model)?;
229
230        // The writer model may be unknown (pair-based selection doesn't require it);
231        // opposition is enforced only when the writer model is actually provided.
232        if request.watched_model.trim().is_empty() {
233            if !request.allow_same_model {
234                // The writer model is unknown so we cannot verify model opposition at
235                // runtime — the review will proceed, but warn so this doesn't go silent.
236                eprintln!(
237                    "{} warning: writer model is unknown; model opposition is only pair-based \
238                     for this review. Pass --watched-model to enforce per-model opposition; \
239                     truth-mirror has no persistent writer-model config key yet.",
240                    crate::messages::diagnostic_prefix()
241                );
242            }
243        } else if !request.allow_same_model
244            && normalized_model(&request.watched_model) == normalized_model(&request.reviewer_model)
245        {
246            return Err(ReviewerError::SameModelWithoutWaiver {
247                watched_model: request.watched_model,
248                reviewer_model: request.reviewer_model,
249            });
250        }
251
252        let invocation = InvocationPlan::for_harness(
253            request.reviewer_harness,
254            &request.reviewer_model,
255            request.reviewer_effort,
256        )?;
257
258        Ok(Self {
259            watched_agent: request.watched_agent,
260            watched_model: request.watched_model,
261            reviewer_harness: request.reviewer_harness,
262            reviewer_model: request.reviewer_model,
263            allow_same_model: request.allow_same_model,
264            invocation,
265        })
266    }
267
268    pub fn run_with<R: ProcessRunner>(
269        &self,
270        prompt: &str,
271        runner: &R,
272    ) -> Result<ProcessOutput, ReviewerError> {
273        runner.run(&self.invocation, prompt)
274    }
275
276    fn reviewer_config(&self) -> ReviewerConfig {
277        ReviewerConfig::new(
278            harness_slug(self.reviewer_harness),
279            self.reviewer_model.clone(),
280            self.allow_same_model,
281        )
282    }
283}
284
285#[derive(Clone, Debug, Eq, PartialEq)]
286pub struct InvocationPlan {
287    pub program: String,
288    pub args: Vec<String>,
289    pub prompt_delivery: PromptDelivery,
290}
291
292impl InvocationPlan {
293    pub fn for_harness(
294        harness: ReviewerHarness,
295        model: &str,
296        effort: Effort,
297    ) -> Result<Self, ReviewerError> {
298        validate_model_present("reviewer", model)?;
299        let model = model.trim();
300        let e = effort.as_str();
301
302        // Reasoning-effort flags verified against source: codex ReasoningEffort
303        // (`-c model_reasoning_effort`), pi `--thinking` (cli/args.js:249), claude
304        // `--effort`. Gemini/OpenCode have no effort flag, so effort is omitted.
305        let plan = match harness {
306            ReviewerHarness::Claude => Self {
307                program: "claude".to_owned(),
308                args: vec![
309                    "--print".to_owned(),
310                    "--model".to_owned(),
311                    model.to_owned(),
312                    "--effort".to_owned(),
313                    // Claude has no `minimal`; clamp to a valid level.
314                    effort.claude_value().to_owned(),
315                ],
316                prompt_delivery: PromptDelivery::Stdin,
317            },
318            ReviewerHarness::Codex => Self {
319                program: "codex".to_owned(),
320                args: vec![
321                    "exec".to_owned(),
322                    "-m".to_owned(),
323                    model.to_owned(),
324                    "-c".to_owned(),
325                    format!("model_reasoning_effort={e}"),
326                ],
327                prompt_delivery: PromptDelivery::PositionalArgument,
328            },
329            ReviewerHarness::Pi => Self {
330                program: "pi".to_owned(),
331                args: vec![
332                    "--model".to_owned(),
333                    model.to_owned(),
334                    "--thinking".to_owned(),
335                    e.to_owned(),
336                    // Read-only tools so the reviewer can grep the repo for the whole
337                    // defect class (Codex `exec` / Claude `-p` have read tools already).
338                    "--tools".to_owned(),
339                    "read,grep,find,ls".to_owned(),
340                    "-p".to_owned(),
341                ],
342                prompt_delivery: PromptDelivery::Stdin,
343            },
344            ReviewerHarness::Gemini => Self {
345                program: "gemini".to_owned(),
346                args: vec!["-m".to_owned(), model.to_owned()],
347                prompt_delivery: PromptDelivery::FlagValue("-p".to_owned()),
348            },
349            ReviewerHarness::Opencode => Self {
350                program: "opencode".to_owned(),
351                args: vec!["run".to_owned(), "--model".to_owned(), model.to_owned()],
352                prompt_delivery: PromptDelivery::PositionalArgument,
353            },
354            ReviewerHarness::Custom => return Err(ReviewerError::UnsupportedCustomHarness),
355        };
356
357        Ok(plan)
358    }
359
360    pub fn args_for_prompt(&self, prompt: &str) -> Vec<String> {
361        let mut args = self.args.clone();
362        match &self.prompt_delivery {
363            PromptDelivery::Stdin => {}
364            PromptDelivery::PositionalArgument => args.push(prompt.to_owned()),
365            PromptDelivery::FlagValue(flag) => {
366                args.push(flag.clone());
367                args.push(prompt.to_owned());
368            }
369        }
370        args
371    }
372}
373
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub enum PromptDelivery {
376    Stdin,
377    PositionalArgument,
378    FlagValue(String),
379}
380
381#[derive(Clone, Debug, Eq, PartialEq)]
382pub struct ProcessOutput {
383    pub status_code: Option<i32>,
384    pub stdout: String,
385    pub stderr: String,
386}
387
388pub trait ProcessRunner {
389    fn run(
390        &self,
391        invocation: &InvocationPlan,
392        prompt: &str,
393    ) -> Result<ProcessOutput, ReviewerError>;
394}
395
396#[derive(Clone, Copy, Debug, Default)]
397pub struct StdProcessRunner;
398
399impl ProcessRunner for StdProcessRunner {
400    fn run(
401        &self,
402        invocation: &InvocationPlan,
403        prompt: &str,
404    ) -> Result<ProcessOutput, ReviewerError> {
405        let mut command = Command::new(&invocation.program);
406        command.args(invocation.args_for_prompt(prompt));
407        command.stdout(Stdio::piped()).stderr(Stdio::piped());
408
409        if invocation.prompt_delivery == PromptDelivery::Stdin {
410            command.stdin(Stdio::piped());
411        }
412
413        let mut child = command.spawn().map_err(ReviewerError::Spawn)?;
414        if invocation.prompt_delivery == PromptDelivery::Stdin {
415            let mut stdin = child.stdin.take().ok_or(ReviewerError::MissingStdinPipe)?;
416            stdin
417                .write_all(prompt.as_bytes())
418                .map_err(ReviewerError::WritePrompt)?;
419        }
420
421        let output = child.wait_with_output().map_err(ReviewerError::Wait)?;
422        Ok(ProcessOutput {
423            status_code: output.status.code(),
424            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
425            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
426        })
427    }
428}
429
430#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct ReviewJob {
432    pub commit_sha: String,
433    pub claim: Claim,
434    pub diff: String,
435    /// Ground-truth constraints + recent trajectory injected into review prompts.
436    pub context: String,
437    pub request: ReviewRequest,
438    pub strict: Option<StrictReviewConfig>,
439    /// When set, this job is a petition re-review for the listed original
440    /// rejection SHA. The reviewer is asked "does <fix sha> materially address
441    /// each finding?", and a `PASS` verdict (the petition's accept — there is
442    /// no separate `ACCEPT` variant on the wire) transitions the original
443    /// rejection to `resolved`; a non-PASS verdict leaves the attempt spent
444    /// (escalating to `needs-human` once the counter hits the bound).
445    pub petition: Option<PetitionContext>,
446}
447
448/// Original rejection payload bundled into a petition re-review.
449#[derive(Clone, Debug, Eq, PartialEq)]
450pub struct PetitionContext {
451    pub original_sha: String,
452    pub fix_sha: String,
453    pub original_claim: String,
454    pub original_summary: String,
455    pub original_findings: Vec<String>,
456    pub original_structured_findings: Vec<StructuredFinding>,
457    pub original_reviewer_model: String,
458    pub attempts_so_far: u32,
459}
460
461#[derive(Clone, Debug, Eq, PartialEq)]
462pub struct StrictReviewConfig {
463    pub arbiter_harness: ReviewerHarness,
464    pub arbiter_model: String,
465    pub arbiter_effort: Effort,
466}
467
468#[derive(Clone, Debug, Eq, PartialEq)]
469pub struct ReviewExecution {
470    pub entries: Vec<LedgerEntry>,
471}
472
473/// If the job carries a petition context, rebuild the request with the
474/// petition-specific prompt so the reviewer is asked the right question.
475/// The reviewer invocation plan is otherwise unchanged; only the prompt
476/// text the reviewer receives is replaced.
477fn materialize_petition_prompt(mut job: ReviewJob) -> ReviewJob {
478    let Some(petition) = &job.petition else {
479        return job;
480    };
481    job.request.prompt = petition_prompt(petition, &job.claim, &job.diff, &job.context);
482    job
483}
484
485pub fn execute_review_job<R: ProcessRunner>(
486    job: ReviewJob,
487    runner: &R,
488    store: &LedgerStore,
489) -> Result<ReviewExecution, ReviewerError> {
490    // If this job carries a petition context, swap the prompt for the petition
491    // template so the reviewer sees the original claim + findings + the fix
492    // diff and is asked whether the fix materially addresses each finding.
493    let job = materialize_petition_prompt(job);
494    let first_plan = ReviewPlan::build(job.request.clone())?;
495    let first_output = first_plan.run_with(&job.request.prompt, runner)?;
496    ensure_process_success(&first_output)?;
497    let first_verdict = ParsedVerdict::parse(&first_output.stdout)?;
498    let first_entry = entry_from_verdict(&job, &first_plan, &first_verdict);
499    store.append_entry(&first_entry)?;
500
501    let mut entries = vec![first_entry];
502    if let Some(strict) = &job.strict
503        && first_verdict.verdict == Verdict::Pass
504        && first_verdict.findings.is_empty()
505    {
506        validate_strict_arbiter(&job.request, strict)?;
507        let strict_prompt = strict_second_pass_prompt(&job, &first_output.stdout);
508        let strict_request = ReviewRequest::new(
509            job.request.watched_agent,
510            job.request.watched_model.clone(),
511            strict.arbiter_harness,
512            strict.arbiter_model.clone(),
513            false,
514            strict_prompt,
515        )
516        .with_effort(strict.arbiter_effort);
517        let strict_plan = ReviewPlan::build(strict_request.clone())?;
518        let strict_output = strict_plan.run_with(&strict_request.prompt, runner)?;
519        ensure_process_success(&strict_output)?;
520        let strict_verdict = ParsedVerdict::parse(&strict_output.stdout)?;
521        let strict_entry = entry_from_verdict(&job, &strict_plan, &strict_verdict);
522        store.append_entry(&strict_entry)?;
523        entries.push(strict_entry);
524    }
525
526    // Petition transitions apply to the FINAL verdict only. Transitioning on
527    // the first PASS and then letting a strict arbiter REJECT would leave the
528    // original rejection resolved by a verdict the arbiter just overturned.
529    if let Some(last) = entries.last() {
530        apply_petition_transition(&job, last, store)?;
531    }
532
533    Ok(ReviewExecution { entries })
534}
535
536/// After a petition reviewer's verdict is recorded, transition the original
537/// rejection: `PASS` (the petition's accept) -> `resolved` (with provenance); anything else ->
538/// increment the attempt counter; on the bounded-th attempt, escalate to
539/// `needs-human`. The bounded max lives in `crate::resolve::MAX_PETITION_ATTEMPTS`.
540fn apply_petition_transition(
541    job: &ReviewJob,
542    entry: &LedgerEntry,
543    store: &LedgerStore,
544) -> Result<(), ReviewerError> {
545    let Some(petition) = &job.petition else {
546        return Ok(());
547    };
548    let max_attempts = crate::resolve::MAX_PETITION_ATTEMPTS;
549    let next_attempts = entry.petition_attempts;
550    let reason = format!(
551        "petition review of fix {} against rejection {} (attempts {})",
552        petition.fix_sha, petition.original_sha, next_attempts
553    );
554
555    if crate::resolve::petition_accepts(entry.verdict) {
556        // PASS or FLAG: the fix materially addresses each finding (FLAG just
557        // carries non-blocking debt alongside — the debt lives on the
558        // petition review entry itself and surfaces via `truth-mirror debt`).
559        store
560            .append_petition_transition(
561                &petition.original_sha,
562                crate::ledger::Disposition::Resolved,
563                crate::ledger::ResolutionKind::Resolved,
564                &reason,
565                next_attempts,
566            )
567            .map_err(ReviewerError::Ledger)?;
568    } else {
569        {
570            // REJECT: the attempt is spent, stay Open unless exhausted.
571            if next_attempts >= max_attempts {
572                store
573                    .escalate_to_needs_human_with_attempts(
574                        &petition.original_sha,
575                        &reason,
576                        next_attempts,
577                    )
578                    .map_err(ReviewerError::Ledger)?;
579            } else {
580                store
581                    .append_petition_transition(
582                        &petition.original_sha,
583                        crate::ledger::Disposition::Open,
584                        crate::ledger::ResolutionKind::Resolved,
585                        &reason,
586                        next_attempts,
587                    )
588                    .map_err(ReviewerError::Ledger)?;
589            }
590        }
591    }
592    Ok(())
593}
594
595/// Rebuild the petition context for a queued petition item from the ledger:
596/// the original rejection's claim, findings, and reviewer, plus the current
597/// attempt counter. The CLI's `resolve --fixed-by` already counted this
598/// attempt when it enqueued, so `attempts_so_far` INCLUDES the in-flight
599/// attempt and the verdict entry carries it unchanged (no double increment).
600fn petition_context_from_ledger(
601    original_sha: &str,
602    fix_sha: &str,
603    store: &LedgerStore,
604) -> Result<PetitionContext, ReviewerError> {
605    let original = store.show(original_sha).map_err(ReviewerError::Ledger)?;
606    let attempts_so_far = store
607        .petition_attempts_for(original_sha)
608        .map_err(ReviewerError::Ledger)?;
609    Ok(PetitionContext {
610        original_sha: original_sha.to_owned(),
611        fix_sha: fix_sha.to_owned(),
612        original_claim: original.claim.clone(),
613        original_summary: original.summary.clone(),
614        original_findings: original.findings.clone(),
615        original_structured_findings: original.structured_findings.clone(),
616        original_reviewer_model: original.reviewer.model.clone(),
617        attempts_so_far,
618    })
619}
620
621#[derive(Clone, Debug, Eq, PartialEq)]
622pub struct ParsedVerdict {
623    pub verdict: Verdict,
624    pub summary: String,
625    pub findings: Vec<String>,
626    pub structured_findings: Vec<StructuredFinding>,
627    pub next_steps: Vec<String>,
628    pub memory_skill_classification: MemorySkillClassification,
629    pub raw: String,
630}
631
632impl ParsedVerdict {
633    pub fn parse(output: &str) -> Result<Self, ReviewerError> {
634        // Reviewer models routinely wrap the verdict in prose and/or a
635        // ```json fenced block despite the prompt asking for raw JSON.
636        // Demanding JSON at byte 0 made the first real review error out and
637        // the watcher queue never drain — so parse tolerantly: raw first,
638        // then the fenced block, then the outermost brace span. The original
639        // raw output is preserved verbatim in `raw` either way.
640        let candidate = extract_verdict_json(output);
641        let parsed: ReviewerJsonOutput =
642            serde_json::from_str(candidate).map_err(|source| ReviewerError::VerdictJson {
643                source,
644                output: output.to_owned(),
645            })?;
646        parsed.validate()?;
647        let findings = parsed
648            .findings
649            .iter()
650            .map(StructuredFinding::display_line)
651            .collect();
652
653        Ok(Self {
654            verdict: parsed.verdict,
655            summary: parsed.summary,
656            findings,
657            structured_findings: parsed.findings,
658            next_steps: parsed.next_steps,
659            memory_skill_classification: parsed.memory_skill,
660            raw: output.to_owned(),
661        })
662    }
663}
664
665/// Pull the verdict JSON out of a reviewer reply that may wrap it in prose
666/// and/or markdown fences. Every candidate is VALIDATED as JSON before it is
667/// returned — a shape that fails to parse falls through to the next strategy
668/// instead of poisoning the caller (leading JSON + trailing prose was exactly
669/// that failure: `starts_with('{')` returned the whole text and serde died on
670/// the trailing characters). Precedence:
671/// 1. the whole (trimmed) output — the documented contract, zero-cost;
672/// 2. the longest valid JSON value PREFIX when the output starts with `{`
673///    (leading verdict, trailing prose);
674/// 3. the first ```json (or bare ```) fenced block that parses;
675/// 4. the first valid JSON value starting at the first `{` anywhere.
676///
677/// Returns a slice of `output`; the caller still surfaces the full original
678/// text in parse errors and stores it verbatim as `raw`.
679fn extract_verdict_json(output: &str) -> &str {
680    fn parses(candidate: &str) -> bool {
681        serde_json::from_str::<serde_json::Value>(candidate).is_ok()
682    }
683    /// The byte length of the first complete JSON value at the start of
684    /// `text`, if any — how we slice a verdict off leading-JSON+prose shapes.
685    fn json_prefix_len(text: &str) -> Option<usize> {
686        let mut stream = serde_json::Deserializer::from_str(text).into_iter::<serde_json::Value>();
687        match stream.next() {
688            Some(Ok(_)) => Some(stream.byte_offset()),
689            _ => None,
690        }
691    }
692
693    let trimmed = output.trim();
694    if trimmed.starts_with('{') {
695        if parses(trimmed) {
696            return trimmed;
697        }
698        if let Some(end) = json_prefix_len(trimmed) {
699            return trimmed[..end].trim();
700        }
701    }
702
703    // Fenced block: find ``` openers, skip the info string (e.g. `json`),
704    // and take the body up to the closing fence.
705    let mut search_from = 0;
706    while let Some(open_rel) = output[search_from..].find("```") {
707        let after_open = search_from + open_rel + 3;
708        let body_start = match output[after_open..].find('\n') {
709            Some(newline_rel) => after_open + newline_rel + 1,
710            None => break,
711        };
712        let Some(close_rel) = output[body_start..].find("```") else {
713            break;
714        };
715        let body = output[body_start..body_start + close_rel].trim();
716        if body.starts_with('{') && parses(body) {
717            return body;
718        }
719        search_from = body_start + close_rel + 3;
720    }
721
722    // Last resort: the first complete JSON value starting at the first `{`
723    // anywhere in the output (handles prose-then-JSON-then-prose).
724    if let Some(first) = output.find('{') {
725        let tail = &output[first..];
726        if let Some(end) = json_prefix_len(tail) {
727            return tail[..end].trim();
728        }
729    }
730
731    trimmed
732}
733
734#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
735struct ReviewerJsonOutput {
736    verdict: Verdict,
737    summary: String,
738    #[serde(default)]
739    findings: Vec<StructuredFinding>,
740    #[serde(default)]
741    next_steps: Vec<String>,
742    memory_skill: MemorySkillClassification,
743}
744
745impl ReviewerJsonOutput {
746    fn validate(&self) -> Result<(), ReviewerError> {
747        if self.summary.trim().is_empty() {
748            return Err(ReviewerError::VerdictSchema {
749                message: "summary must not be empty".to_owned(),
750            });
751        }
752        self.memory_skill
753            .validate_for_verdict(self.verdict)
754            .map_err(|message| ReviewerError::VerdictSchema { message })?;
755
756        for finding in &self.findings {
757            if finding.title.trim().is_empty() {
758                return Err(ReviewerError::VerdictSchema {
759                    message: "finding title must not be empty".to_owned(),
760                });
761            }
762            if finding.body.trim().is_empty() {
763                return Err(ReviewerError::VerdictSchema {
764                    message: "finding body must not be empty".to_owned(),
765                });
766            }
767            if finding.file.trim().is_empty() {
768                return Err(ReviewerError::VerdictSchema {
769                    message: "finding file must not be empty".to_owned(),
770                });
771            }
772            if finding.line_start == 0 || finding.line_end == 0 {
773                return Err(ReviewerError::VerdictSchema {
774                    message: "finding lines must be one-based".to_owned(),
775                });
776            }
777            if finding.line_end < finding.line_start {
778                return Err(ReviewerError::VerdictSchema {
779                    message: "finding line_end must be greater than or equal to line_start"
780                        .to_owned(),
781                });
782            }
783            if finding.confidence > 100 {
784                return Err(ReviewerError::VerdictSchema {
785                    message: "finding confidence must be between 0 and 100".to_owned(),
786                });
787            }
788            if finding.recommendation.trim().is_empty() {
789                return Err(ReviewerError::VerdictSchema {
790                    message: "finding recommendation must not be empty".to_owned(),
791                });
792            }
793        }
794
795        if self.verdict == Verdict::Pass && !self.findings.is_empty() {
796            return Err(ReviewerError::VerdictSchema {
797                message: "PASS verdict must not include findings".to_owned(),
798            });
799        }
800        if self.verdict == Verdict::Reject && self.findings.is_empty() {
801            return Err(ReviewerError::VerdictSchema {
802                message: "REJECT verdict must include at least one finding".to_owned(),
803            });
804        }
805        if self.verdict == Verdict::Flag && self.findings.is_empty() {
806            return Err(ReviewerError::VerdictSchema {
807                message: "FLAG verdict must include at least one finding (the debt being surfaced, in the findings array)"
808                    .to_owned(),
809            });
810        }
811
812        Ok(())
813    }
814}
815
816#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
817#[serde(rename_all = "kebab-case")]
818pub enum ReviewRunStatus {
819    Queued,
820    Running,
821    Completed,
822    Failed,
823    Cancelled,
824}
825
826impl std::fmt::Display for ReviewRunStatus {
827    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
828        match self {
829            Self::Queued => formatter.write_str("queued"),
830            Self::Running => formatter.write_str("running"),
831            Self::Completed => formatter.write_str("completed"),
832            Self::Failed => formatter.write_str("failed"),
833            Self::Cancelled => formatter.write_str("cancelled"),
834        }
835    }
836}
837
838#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
839pub struct ReviewRun {
840    pub id: String,
841    pub commit_sha: String,
842    pub target: String,
843    pub status: ReviewRunStatus,
844    pub phase: String,
845    pub ledger_entries: usize,
846    pub error: Option<String>,
847    /// PID of the process executing this run while it is `Running`. Persisted so a
848    /// `review status`/`cancel` can tell a live worker from a silently-dead one and
849    /// reap the zombie. `None` once the run reaches a terminal state (or for legacy
850    /// records written before pid tracking existed).
851    #[serde(default)]
852    pub worker_pid: Option<u32>,
853    pub created_at_unix: u64,
854    pub updated_at_unix: u64,
855    pub started_at_unix: Option<u64>,
856    pub completed_at_unix: Option<u64>,
857    #[serde(default, skip_serializing_if = "Option::is_none")]
858    pub entire_checkpoint: Option<EntireCheckpointRef>,
859}
860
861#[derive(Clone, Debug, Default, Eq, PartialEq)]
862pub struct ReviewRunStatusCounts {
863    pub queued: usize,
864    pub running: usize,
865    pub completed: usize,
866    pub failed: usize,
867    pub cancelled: usize,
868    pub skipped_records: usize,
869}
870
871impl ReviewRunStatusCounts {
872    fn add(&mut self, status: ReviewRunStatus) {
873        match status {
874            ReviewRunStatus::Queued => self.queued += 1,
875            ReviewRunStatus::Running => self.running += 1,
876            ReviewRunStatus::Completed => self.completed += 1,
877            ReviewRunStatus::Failed => self.failed += 1,
878            ReviewRunStatus::Cancelled => self.cancelled += 1,
879        }
880    }
881}
882
883#[derive(Deserialize)]
884struct ReviewRunStatusRecord {
885    status: ReviewRunStatus,
886}
887
888impl ReviewRun {
889    #[cfg(test)]
890    fn queued(
891        id: impl Into<String>,
892        commit_sha: impl Into<String>,
893        target: impl Into<String>,
894    ) -> Self {
895        Self::queued_with_provenance(id, commit_sha, target, None)
896    }
897
898    fn queued_with_provenance(
899        id: impl Into<String>,
900        commit_sha: impl Into<String>,
901        target: impl Into<String>,
902        entire_checkpoint: Option<EntireCheckpointRef>,
903    ) -> Self {
904        let timestamp = unix_now();
905        Self {
906            id: id.into(),
907            commit_sha: commit_sha.into(),
908            target: target.into(),
909            status: ReviewRunStatus::Queued,
910            phase: "queued".to_owned(),
911            ledger_entries: 0,
912            error: None,
913            worker_pid: None,
914            created_at_unix: timestamp,
915            updated_at_unix: timestamp,
916            started_at_unix: None,
917            completed_at_unix: None,
918            entire_checkpoint,
919        }
920    }
921
922    fn mark_running(&mut self, phase: impl Into<String>) {
923        let timestamp = unix_now();
924        self.status = ReviewRunStatus::Running;
925        self.phase = phase.into();
926        self.error = None;
927        self.worker_pid = Some(std::process::id());
928        self.updated_at_unix = timestamp;
929        self.started_at_unix = Some(timestamp);
930        self.completed_at_unix = None;
931    }
932
933    fn mark_completed(&mut self, ledger_entries: usize) {
934        let timestamp = unix_now();
935        self.status = ReviewRunStatus::Completed;
936        self.phase = "completed".to_owned();
937        self.ledger_entries = ledger_entries;
938        self.error = None;
939        self.worker_pid = None;
940        self.updated_at_unix = timestamp;
941        self.completed_at_unix = Some(timestamp);
942    }
943
944    fn mark_failed(&mut self, error: impl Into<String>) {
945        let timestamp = unix_now();
946        self.status = ReviewRunStatus::Failed;
947        self.phase = "failed".to_owned();
948        self.error = Some(error.into());
949        self.worker_pid = None;
950        self.updated_at_unix = timestamp;
951        self.completed_at_unix = Some(timestamp);
952    }
953
954    fn mark_cancelled(&mut self) {
955        let timestamp = unix_now();
956        self.status = ReviewRunStatus::Cancelled;
957        self.phase = "cancelled".to_owned();
958        self.error = None;
959        self.worker_pid = None;
960        self.updated_at_unix = timestamp;
961        self.completed_at_unix = Some(timestamp);
962    }
963
964    /// Reconcile a `Running` run against process liveness. If its recorded worker
965    /// pid is gone, the worker died without recording a verdict — flip the zombie to
966    /// `Failed` with an explanatory reason. Returns whether the run was changed.
967    ///
968    /// A run with no recorded pid (legacy record) is left untouched: liveness cannot
969    /// be proven, so it is never auto-failed here — only an explicit forced cancel
970    /// reaps it.
971    fn reconcile_liveness(&mut self, is_alive: impl Fn(u32) -> bool) -> bool {
972        if self.status != ReviewRunStatus::Running {
973            return false;
974        }
975        match self.worker_pid {
976            Some(pid) if !is_alive(pid) => {
977                self.mark_failed(stale_worker_reason(pid));
978                true
979            }
980            _ => false,
981        }
982    }
983}
984
985/// Reason recorded when a running review is found abandoned by a dead worker.
986fn stale_worker_reason(pid: u32) -> String {
987    format!("worker process {pid} exited without recording a verdict (stale run)")
988}
989
990/// Force-terminate a process with SIGKILL. Used by `review cancel --force` on a
991/// worker that is still alive.
992fn kill_pid(pid: u32) -> Result<(), ReviewerError> {
993    let status = Command::new("kill")
994        .arg("-KILL")
995        .arg(pid.to_string())
996        .stdout(Stdio::null())
997        .stderr(Stdio::null())
998        .status()
999        .map_err(ReviewerError::KillWorker)?;
1000    if status.success() || !crate::watcher::pid_is_alive(pid) {
1001        Ok(())
1002    } else {
1003        Err(ReviewerError::KillWorkerFailed { pid })
1004    }
1005}
1006
1007#[derive(Clone, Debug)]
1008pub struct ReviewRunStore {
1009    root: PathBuf,
1010}
1011
1012impl ReviewRunStore {
1013    pub fn new(root: impl Into<PathBuf>) -> Self {
1014        Self { root: root.into() }
1015    }
1016
1017    pub fn runs_dir(&self) -> PathBuf {
1018        self.root.join(REVIEW_RUNS_DIR)
1019    }
1020
1021    pub fn path(&self, id: &str) -> PathBuf {
1022        self.runs_dir().join(format!("{id}.json"))
1023    }
1024
1025    pub fn create_queued(
1026        &self,
1027        commit_sha: &str,
1028        target: impl Into<String>,
1029    ) -> Result<ReviewRun, ReviewerError> {
1030        let checkpoint = entire_checkpoint_for_current_repo(commit_sha);
1031        let run = ReviewRun::queued_with_provenance(
1032            generate_run_id(commit_sha),
1033            commit_sha,
1034            target,
1035            checkpoint,
1036        );
1037        self.write(&run)?;
1038        Ok(run)
1039    }
1040
1041    fn ensure_queued(
1042        &self,
1043        run_id: &str,
1044        commit_sha: &str,
1045        target: &str,
1046    ) -> Result<ReviewRun, ReviewerError> {
1047        match self.read(run_id) {
1048            Ok(run) => Ok(run),
1049            Err(ReviewerError::ReviewRunNotFound { .. }) => {
1050                let checkpoint = entire_checkpoint_for_current_repo(commit_sha);
1051                let run = ReviewRun::queued_with_provenance(run_id, commit_sha, target, checkpoint);
1052                self.write(&run)?;
1053                Ok(run)
1054            }
1055            Err(error) => Err(error),
1056        }
1057    }
1058
1059    pub fn read(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
1060        let path = self.path(id);
1061        let contents = fs::read_to_string(&path).map_err(|source| match source.kind() {
1062            io::ErrorKind::NotFound => ReviewerError::ReviewRunNotFound { id: id.to_owned() },
1063            _ => ReviewerError::RunIo(source),
1064        })?;
1065        serde_json::from_str(&contents).map_err(ReviewerError::RunJson)
1066    }
1067
1068    pub fn list(&self) -> Result<Vec<ReviewRun>, ReviewerError> {
1069        let dir = self.runs_dir();
1070        let entries = match fs::read_dir(&dir) {
1071            Ok(entries) => entries,
1072            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1073            Err(error) => return Err(ReviewerError::RunIo(error)),
1074        };
1075        let mut runs: Vec<ReviewRun> = Vec::new();
1076        for entry in entries {
1077            let entry = entry.map_err(ReviewerError::RunIo)?;
1078            if entry
1079                .path()
1080                .extension()
1081                .is_none_or(|extension| extension != "json")
1082            {
1083                continue;
1084            }
1085            let contents = fs::read_to_string(entry.path()).map_err(ReviewerError::RunIo)?;
1086            runs.push(serde_json::from_str(&contents).map_err(ReviewerError::RunJson)?);
1087        }
1088        runs.sort_by(|left, right| {
1089            right
1090                .updated_at_unix
1091                .cmp(&left.updated_at_unix)
1092                .then_with(|| right.id.cmp(&left.id))
1093        });
1094        Ok(runs)
1095    }
1096
1097    pub fn status_counts(&self) -> Result<ReviewRunStatusCounts, ReviewerError> {
1098        let dir = self.runs_dir();
1099        let entries = match fs::read_dir(&dir) {
1100            Ok(entries) => entries,
1101            Err(error) if error.kind() == io::ErrorKind::NotFound => {
1102                return Ok(ReviewRunStatusCounts::default());
1103            }
1104            Err(error) => return Err(ReviewerError::RunIo(error)),
1105        };
1106        let mut counts = ReviewRunStatusCounts::default();
1107        for entry in entries {
1108            let Ok(entry) = entry else {
1109                counts.skipped_records += 1;
1110                continue;
1111            };
1112            let path = entry.path();
1113            if path.extension().is_none_or(|extension| extension != "json") {
1114                continue;
1115            }
1116            let Ok(contents) = fs::read_to_string(&path) else {
1117                counts.skipped_records += 1;
1118                continue;
1119            };
1120            let Ok(record) = serde_json::from_str::<ReviewRunStatusRecord>(&contents) else {
1121                counts.skipped_records += 1;
1122                continue;
1123            };
1124            counts.add(record.status);
1125        }
1126        Ok(counts)
1127    }
1128
1129    pub fn latest_result(&self) -> Result<ReviewRun, ReviewerError> {
1130        self.list()?
1131            .into_iter()
1132            .find(|run| {
1133                matches!(
1134                    run.status,
1135                    ReviewRunStatus::Completed
1136                        | ReviewRunStatus::Failed
1137                        | ReviewRunStatus::Cancelled
1138                )
1139            })
1140            .ok_or(ReviewerError::NoReviewRuns)
1141    }
1142
1143    pub fn mark_running(&self, id: &str, phase: &str) -> Result<ReviewRun, ReviewerError> {
1144        let mut run = self.read(id)?;
1145        run.mark_running(phase);
1146        self.write(&run)?;
1147        Ok(run)
1148    }
1149
1150    pub fn mark_completed(
1151        &self,
1152        id: &str,
1153        ledger_entries: usize,
1154    ) -> Result<ReviewRun, ReviewerError> {
1155        let mut run = self.read(id)?;
1156        run.mark_completed(ledger_entries);
1157        self.write(&run)?;
1158        Ok(run)
1159    }
1160
1161    pub fn mark_failed(
1162        &self,
1163        id: &str,
1164        error: impl Into<String>,
1165    ) -> Result<ReviewRun, ReviewerError> {
1166        let mut run = self.read(id)?;
1167        run.mark_failed(error);
1168        self.write(&run)?;
1169        Ok(run)
1170    }
1171
1172    pub fn cancel_queued(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
1173        let mut run = self.read(id)?;
1174        if run.status != ReviewRunStatus::Queued {
1175            return Err(ReviewerError::CannotCancelReview {
1176                id: id.to_owned(),
1177                status: run.status,
1178            });
1179        }
1180        run.mark_cancelled();
1181        self.write(&run)?;
1182        Ok(run)
1183    }
1184
1185    /// Read a run and, if it is a `Running` zombie (recorded worker pid is dead),
1186    /// flip it to `Failed` and persist the change so `review status` reports the
1187    /// truth instead of a run stuck `running` forever.
1188    pub fn read_reconciled(&self, id: &str) -> Result<ReviewRun, ReviewerError> {
1189        let mut run = self.read(id)?;
1190        if run.reconcile_liveness(crate::watcher::pid_is_alive) {
1191            self.write(&run)?;
1192        }
1193        Ok(run)
1194    }
1195
1196    /// List all runs, reconciling any `Running` zombies to `Failed` and persisting
1197    /// the correction, so a dead worker never lingers as `running`.
1198    pub fn list_reconciled(&self) -> Result<Vec<ReviewRun>, ReviewerError> {
1199        let mut runs = self.list()?;
1200        for run in &mut runs {
1201            if run.reconcile_liveness(crate::watcher::pid_is_alive) {
1202                self.write(run)?;
1203            }
1204        }
1205        Ok(runs)
1206    }
1207
1208    /// Cancel a review run, tolerating a running-but-dead worker.
1209    ///
1210    /// - `Queued` runs cancel outright.
1211    /// - A `Running` run whose worker pid is dead is reaped (marked `Failed` stale),
1212    ///   so a zombie can always be cleaned up.
1213    /// - A `Running` run with a live worker is refused unless `force`, which SIGKILLs
1214    ///   the worker and then marks the run `Cancelled`.
1215    /// - A `Running` legacy run with no recorded pid can only be reaped with `force`.
1216    /// - Terminal runs (completed/failed/cancelled) are refused.
1217    pub fn cancel(&self, id: &str, force: bool) -> Result<ReviewRun, ReviewerError> {
1218        let mut run = self.read(id)?;
1219        match run.status {
1220            ReviewRunStatus::Queued => run.mark_cancelled(),
1221            ReviewRunStatus::Running => match run.worker_pid {
1222                Some(pid) if crate::watcher::pid_is_alive(pid) => {
1223                    if !force {
1224                        return Err(ReviewerError::ReviewRunStillAlive {
1225                            id: id.to_owned(),
1226                            pid,
1227                        });
1228                    }
1229                    kill_pid(pid)?;
1230                    run.mark_cancelled();
1231                }
1232                Some(pid) => run.mark_failed(stale_worker_reason(pid)),
1233                None => {
1234                    if !force {
1235                        return Err(ReviewerError::ReviewRunLivenessUnknown { id: id.to_owned() });
1236                    }
1237                    run.mark_failed("worker liveness could not be verified; force-cancelled");
1238                }
1239            },
1240            terminal => {
1241                return Err(ReviewerError::CannotCancelReview {
1242                    id: id.to_owned(),
1243                    status: terminal,
1244                });
1245            }
1246        }
1247        self.write(&run)?;
1248        Ok(run)
1249    }
1250
1251    fn write(&self, run: &ReviewRun) -> Result<(), ReviewerError> {
1252        fs::create_dir_all(self.runs_dir()).map_err(ReviewerError::RunIo)?;
1253        let bytes = serde_json::to_vec_pretty(run).map_err(ReviewerError::RunJson)?;
1254        fs::write(self.path(&run.id), bytes).map_err(ReviewerError::RunIo)
1255    }
1256}
1257
1258fn entire_checkpoint_for_current_repo(commit_sha: &str) -> Option<EntireCheckpointRef> {
1259    let repo_root = current_repo_root().unwrap_or_else(|| PathBuf::from("."));
1260    provenance::entire_checkpoint_for_commit(&repo_root, commit_sha)
1261}
1262
1263fn current_repo_root() -> Option<PathBuf> {
1264    let output = Command::new("git")
1265        .args(["rev-parse", "--show-toplevel"])
1266        .output()
1267        .ok()?;
1268    output
1269        .status
1270        .success()
1271        .then(|| PathBuf::from(String::from_utf8_lossy(&output.stdout).trim()))
1272}
1273
1274#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
1275pub struct QueuedReview {
1276    #[serde(default)]
1277    pub run_id: String,
1278    pub commit_sha: String,
1279    pub enqueued_at_unix: u64,
1280    /// When set, this queued item is a petition re-review: `commit_sha` is the
1281    /// FIX commit and this field names the original rejection it petitions.
1282    /// `None` (the default, omitted on the wire) is a normal commit review, so
1283    /// pre-existing queue lines keep parsing.
1284    #[serde(default, skip_serializing_if = "Option::is_none")]
1285    pub petition_for: Option<String>,
1286}
1287
1288#[derive(Clone, Debug)]
1289pub struct ReviewQueue {
1290    root: PathBuf,
1291}
1292
1293#[derive(Clone, Debug, Default, Eq, PartialEq)]
1294pub struct ReviewQueueSummary {
1295    pub pending_count: usize,
1296    pub oldest_enqueued_at_unix: Option<u64>,
1297}
1298
1299impl ReviewQueueSummary {
1300    pub fn oldest_age_secs_at(&self, now: u64) -> Option<u64> {
1301        self.oldest_enqueued_at_unix
1302            .map(|oldest| now.saturating_sub(oldest))
1303    }
1304}
1305
1306impl ReviewQueue {
1307    pub fn new(root: impl Into<PathBuf>) -> Self {
1308        Self { root: root.into() }
1309    }
1310
1311    pub fn path(&self) -> PathBuf {
1312        self.root.join(REVIEW_QUEUE_FILE)
1313    }
1314
1315    pub fn enqueue(&self, commit_sha: impl Into<String>) -> Result<QueuedReview, ReviewerError> {
1316        self.enqueue_item(commit_sha.into(), None)
1317    }
1318
1319    /// Enqueue a petition re-review: `fix_sha` is reviewed with the petition
1320    /// prompt, and its verdict transitions the original rejection
1321    /// (`original_sha`). This is the queue leg of `truth-mirror resolve
1322    /// --fixed-by` — without it the watcher would never build a petition job.
1323    pub fn enqueue_petition(
1324        &self,
1325        fix_sha: impl Into<String>,
1326        original_sha: impl Into<String>,
1327    ) -> Result<QueuedReview, ReviewerError> {
1328        self.enqueue_item(fix_sha.into(), Some(original_sha.into()))
1329    }
1330
1331    fn enqueue_item(
1332        &self,
1333        commit_sha: String,
1334        petition_for: Option<String>,
1335    ) -> Result<QueuedReview, ReviewerError> {
1336        fs::create_dir_all(&self.root).map_err(ReviewerError::QueueIo)?;
1337        let target = if petition_for.is_some() {
1338            "petition"
1339        } else {
1340            "commit"
1341        };
1342        let run = ReviewRunStore::new(&self.root).create_queued(&commit_sha, target)?;
1343        let item = QueuedReview {
1344            run_id: run.id,
1345            commit_sha,
1346            enqueued_at_unix: unix_now(),
1347            petition_for,
1348        };
1349        let mut file = fs::OpenOptions::new()
1350            .create(true)
1351            .append(true)
1352            .open(self.path())
1353            .map_err(ReviewerError::QueueIo)?;
1354        serde_json::to_writer(&mut file, &item).map_err(ReviewerError::QueueJson)?;
1355        writeln!(file).map_err(ReviewerError::QueueIo)?;
1356        Ok(item)
1357    }
1358
1359    pub fn pending(&self) -> Result<Vec<QueuedReview>, ReviewerError> {
1360        let contents = match fs::read_to_string(self.path()) {
1361            Ok(contents) => contents,
1362            Err(error) if error.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()),
1363            Err(error) => return Err(ReviewerError::QueueIo(error)),
1364        };
1365
1366        contents
1367            .lines()
1368            .filter(|line| !line.trim().is_empty())
1369            .map(|line| serde_json::from_str(line).map_err(ReviewerError::QueueJson))
1370            .collect()
1371    }
1372
1373    pub fn summary(&self) -> Result<ReviewQueueSummary, ReviewerError> {
1374        let pending = self.pending()?;
1375        Ok(ReviewQueueSummary {
1376            pending_count: pending.len(),
1377            oldest_enqueued_at_unix: pending.iter().map(|item| item.enqueued_at_unix).min(),
1378        })
1379    }
1380
1381    /// Drop every queued item for `sha`, preserving any items appended for other
1382    /// commits. Called after a commit is reviewed so a drain never repeats work.
1383    pub fn remove_sha(&self, sha: &str) -> Result<(), ReviewerError> {
1384        let remaining: Vec<QueuedReview> = self
1385            .pending()?
1386            .into_iter()
1387            .filter(|item| item.commit_sha != sha)
1388            .collect();
1389        self.rewrite(&remaining)
1390    }
1391
1392    fn rewrite(&self, items: &[QueuedReview]) -> Result<(), ReviewerError> {
1393        if items.is_empty() {
1394            return match fs::remove_file(self.path()) {
1395                Ok(()) => Ok(()),
1396                Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
1397                Err(error) => Err(ReviewerError::QueueIo(error)),
1398            };
1399        }
1400
1401        let mut file = fs::File::create(self.path()).map_err(ReviewerError::QueueIo)?;
1402        for item in items {
1403            serde_json::to_writer(&mut file, item).map_err(ReviewerError::QueueJson)?;
1404            writeln!(file).map_err(ReviewerError::QueueIo)?;
1405        }
1406        Ok(())
1407    }
1408
1409    /// Drop the queued item matching BOTH the commit SHA and the petition
1410    /// identity, preserving sibling rows for the same SHA (a fix commit's own
1411    /// review and a petition review of it are distinct queue items).
1412    pub fn remove_item(&self, sha: &str, petition_for: Option<&str>) -> Result<(), ReviewerError> {
1413        let remaining: Vec<QueuedReview> = self
1414            .pending()?
1415            .into_iter()
1416            .filter(|item| {
1417                !(item.commit_sha == sha && item.petition_for.as_deref() == petition_for)
1418            })
1419            .collect();
1420        self.rewrite(&remaining)
1421    }
1422
1423    pub fn remove_run_id(&self, run_id: &str) -> Result<(), ReviewerError> {
1424        let remaining: Vec<QueuedReview> = self
1425            .pending()?
1426            .into_iter()
1427            .filter(|item| item.run_id != run_id)
1428            .collect();
1429        self.rewrite(&remaining)
1430    }
1431}
1432
1433/// Loads the claim and diff for a commit so the reviewer can run against it.
1434/// Abstracted so `drain_once` can be unit-tested without a real git repository.
1435pub trait MaterialLoader {
1436    fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError>;
1437}
1438
1439#[derive(Clone, Debug, Default)]
1440pub struct GitMaterialLoader {
1441    /// Evidence-pointer patterns from config so async review parses claims the
1442    /// same way the commit-msg gate did (a repo `jira:` pointer stays valid).
1443    pub evidence_patterns: Vec<String>,
1444}
1445
1446impl GitMaterialLoader {
1447    pub fn from_config(config: &config::TruthMirrorConfig) -> Self {
1448        Self::with_patterns(config.gates.to_policy().evidence_patterns)
1449    }
1450
1451    pub fn with_patterns(evidence_patterns: Vec<String>) -> Self {
1452        Self { evidence_patterns }
1453    }
1454}
1455
1456impl MaterialLoader for GitMaterialLoader {
1457    fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError> {
1458        let message = git_output(["show", "--format=%B", "--no-patch", sha])?;
1459        let diff = git_output(["show", "--format=", "--patch", sha])?;
1460        let claim = if self.evidence_patterns.is_empty() {
1461            Claim::parse(&message)?
1462        } else {
1463            Claim::parse_with(&message, &self.evidence_patterns)?
1464        };
1465        Ok((claim, diff))
1466    }
1467}
1468
1469#[derive(Clone, Debug, Default, Eq, PartialEq)]
1470pub struct DrainReport {
1471    pub reviewed: Vec<String>,
1472    /// Queue items skipped after a terminal material-loading failure.
1473    pub failed: Vec<String>,
1474    pub ledger_entries: usize,
1475}
1476
1477/// Review every distinct queued commit exactly once, record verdicts, and remove
1478/// each commit from the queue as soon as its review lands. A material-loading
1479/// failure is terminal for that run: it is recorded and removed so one malformed
1480/// claim never strands the rest of the queue. Reviewer execution failures remain
1481/// queued for retry.
1482pub fn drain_once<R: ProcessRunner, L: MaterialLoader>(
1483    queue: &ReviewQueue,
1484    loader: &L,
1485    selection: &ReviewSelection,
1486    context: &str,
1487    runner: &R,
1488    store: &LedgerStore,
1489    config: &config::TruthMirrorConfig,
1490) -> Result<DrainReport, ReviewerError> {
1491    let pending = queue.pending()?;
1492    let run_store = ReviewRunStore::new(&queue.root);
1493    // Queue identity is (commit_sha, petition_for), NOT the bare SHA: a fix
1494    // commit's own post-commit review and a petition review of that same fix
1495    // (or two petitions for different rejections sharing one fix) are
1496    // distinct jobs — deduping on SHA alone silently cancelled one of them.
1497    let mut seen = std::collections::BTreeSet::new();
1498    let mut order = Vec::new();
1499    for item in &pending {
1500        let identity = (item.commit_sha.clone(), item.petition_for.clone());
1501        if seen.insert(identity) {
1502            order.push(item.clone());
1503        } else if !item.run_id.trim().is_empty() {
1504            match run_store.read(&item.run_id) {
1505                Ok(run) if run.status == ReviewRunStatus::Queued => {
1506                    run_store.cancel_queued(&item.run_id)?;
1507                }
1508                _ => {}
1509            }
1510        }
1511    }
1512
1513    let mut report = DrainReport::default();
1514    for item in order {
1515        let sha = item.commit_sha;
1516        let run_id = if item.run_id.trim().is_empty() {
1517            generate_run_id(&sha)
1518        } else {
1519            item.run_id
1520        };
1521        let target = if item.petition_for.is_some() {
1522            "petition"
1523        } else {
1524            "commit"
1525        };
1526        let run = run_store.ensure_queued(&run_id, &sha, target)?;
1527        if run.status == ReviewRunStatus::Cancelled {
1528            queue.remove_item(&sha, item.petition_for.as_deref())?;
1529            continue;
1530        }
1531        run_store.mark_running(&run_id, "reviewing")?;
1532        let (claim, diff) = match loader.load(&sha) {
1533            Ok(material) => material,
1534            Err(error) => {
1535                run_store.mark_failed(&run_id, error.to_string())?;
1536                queue.remove_item(&sha, item.petition_for.as_deref())?;
1537                report.failed.push(sha);
1538                continue;
1539            }
1540        };
1541        // Petition items rebuild the original rejection's payload from the
1542        // ledger so execute_review_job can swap in the petition prompt and
1543        // apply_petition_transition can close (or escalate) the rejection.
1544        let petition = match &item.petition_for {
1545            Some(original_sha) => Some(petition_context_from_ledger(original_sha, &sha, store)?),
1546            None => None,
1547        };
1548        let prompt = first_pass_prompt(&claim, &diff, context);
1549        let job = ReviewJob {
1550            commit_sha: sha.clone(),
1551            claim,
1552            diff,
1553            context: context.to_owned(),
1554            request: selection.request_for(prompt),
1555            strict: selection.strict.clone(),
1556            petition,
1557        };
1558        let execution = match execute_review_job(job, runner, store) {
1559            Ok(execution) => execution,
1560            Err(error) => {
1561                let _ = run_store.mark_failed(&run_id, error.to_string());
1562                return Err(error);
1563            }
1564        };
1565        record_memory_skill_outcome(
1566            &queue.root,
1567            config,
1568            &run_id,
1569            &sha,
1570            "watch-drain",
1571            &execution.entries,
1572        );
1573        report.ledger_entries += execution.entries.len();
1574        run_store.mark_completed(&run_id, execution.entries.len())?;
1575        // Remove exactly THIS item — remove_sha would also delete a sibling
1576        // row for the same SHA with a different petition identity.
1577        queue.remove_item(&sha, item.petition_for.as_deref())?;
1578        report.reviewed.push(sha);
1579    }
1580
1581    Ok(report)
1582}
1583
1584fn record_memory_skill_outcome(
1585    state_dir: &Path,
1586    config: &config::TruthMirrorConfig,
1587    run_id: &str,
1588    commit_sha: &str,
1589    phase: &str,
1590    entries: &[LedgerEntry],
1591) {
1592    if !is_full_git_sha(commit_sha) {
1593        tracing::debug!(
1594            run_id = %run_id,
1595            commit_sha = %commit_sha,
1596            phase = %phase,
1597            "memory-skill extraction skipped for non-commit review target"
1598        );
1599        return;
1600    }
1601    match crate::memory_skill::evaluate_review_completion(state_dir, config, run_id, entries) {
1602        Ok(_) => {}
1603        Err(crate::memory_skill::MemorySkillError::ScanRejected { reason }) => {
1604            tracing::info!(
1605                run_id = %run_id,
1606                commit_sha = %commit_sha,
1607                phase = %phase,
1608                memory_skill_outcome = "scan_rejected",
1609                reason = %reason,
1610                "memory-skill extraction skipped by scan gate"
1611            );
1612        }
1613        Err(error) => {
1614            let error_kind = memory_skill_error_kind(&error);
1615            tracing::warn!(
1616                run_id = %run_id,
1617                commit_sha = %commit_sha,
1618                phase = %phase,
1619                memory_skill_outcome = "failed",
1620                memory_skill_error_kind = error_kind,
1621                error = %error,
1622                "memory-skill extraction failed after review completion"
1623            );
1624        }
1625    }
1626}
1627
1628fn memory_skill_error_kind(error: &crate::memory_skill::MemorySkillError) -> &'static str {
1629    match error {
1630        crate::memory_skill::MemorySkillError::Io(_) => "io",
1631        crate::memory_skill::MemorySkillError::Json(_) => "json",
1632        crate::memory_skill::MemorySkillError::Ledger(_) => "ledger",
1633        crate::memory_skill::MemorySkillError::CandidateNotFound { .. } => "candidate_not_found",
1634        crate::memory_skill::MemorySkillError::AdvisoryNotFound { .. } => "advisory_not_found",
1635        crate::memory_skill::MemorySkillError::EmptyRejectReason => "empty_reject_reason",
1636        crate::memory_skill::MemorySkillError::EmptySupersedeReason => "empty_supersede_reason",
1637        crate::memory_skill::MemorySkillError::SelfSupersede { .. } => "self_supersede",
1638        crate::memory_skill::MemorySkillError::InvalidSupersedeReplacement { .. } => {
1639            "invalid_supersede_replacement"
1640        }
1641        crate::memory_skill::MemorySkillError::InvalidTransition { .. } => "invalid_transition",
1642        crate::memory_skill::MemorySkillError::TransitionLocked { .. } => "transition_locked",
1643        crate::memory_skill::MemorySkillError::UnsafeGlobalWrite { .. } => "unsafe_global_write",
1644        crate::memory_skill::MemorySkillError::UnsafeApprovedPath { .. } => "unsafe_approved_path",
1645        crate::memory_skill::MemorySkillError::UnsafeStatePath { .. } => "unsafe_state_path",
1646        crate::memory_skill::MemorySkillError::ScanRejected { .. } => "scan_rejected",
1647        crate::memory_skill::MemorySkillError::RenderedSkillTooLarge { .. } => {
1648            "rendered_skill_too_large"
1649        }
1650    }
1651}
1652
1653/// Build the ground-truth + trajectory context block for review prompts.
1654/// Best-effort: an unavailable repo or provider yields an empty block.
1655fn review_context(config: &config::TruthMirrorConfig) -> String {
1656    let repo_root = match git_output(["rev-parse", "--show-toplevel"]) {
1657        Ok(root) => PathBuf::from(root.trim()),
1658        Err(_) => return String::new(),
1659    };
1660    let provider = crate::context::trajectory_provider(&repo_root, &config.history);
1661    crate::context::build_review_context(
1662        &repo_root,
1663        &config.ground_truth,
1664        &config.history,
1665        Some(provider.as_ref()),
1666    )
1667    .unwrap_or_default()
1668}
1669
1670pub fn run_watch_command(
1671    args: cli::WatchArgs,
1672    state_dir: &Path,
1673    config: &config::TruthMirrorConfig,
1674) -> Result<ExitCode> {
1675    let selection = ReviewSelection::resolve(
1676        args.watched_agent,
1677        args.watched_model,
1678        args.reviewer_harness,
1679        args.reviewer_model,
1680        args.reviewer_effort,
1681        args.allow_same_model,
1682        config,
1683    )?;
1684    let queue = ReviewQueue::new(state_dir);
1685    let store = LedgerStore::new(state_dir);
1686    let loader = GitMaterialLoader::from_config(config);
1687    let runner = StdProcessRunner;
1688
1689    if args.once {
1690        let context = review_context(config);
1691        let report = drain_once(
1692            &queue, &loader, &selection, &context, &runner, &store, config,
1693        )?;
1694        println!(
1695            "truth-mirror watch: reviewed {} commit(s), skipped {} failed item(s), wrote {} ledger entrie(s)",
1696            report.reviewed.len(),
1697            report.failed.len(),
1698            report.ledger_entries
1699        );
1700        return Ok(ExitCode::SUCCESS);
1701    }
1702
1703    let interval = std::time::Duration::from_secs(args.poll_secs.max(1));
1704
1705    if args.until_empty {
1706        return run_until_empty(
1707            &queue, &loader, &selection, &runner, &store, config, state_dir, args.grace, interval,
1708        );
1709    }
1710
1711    loop {
1712        // Rebuild context each poll so ground truth and trajectory stay current.
1713        let context = review_context(config);
1714        let report = drain_once(
1715            &queue, &loader, &selection, &context, &runner, &store, config,
1716        )?;
1717        if !report.reviewed.is_empty() {
1718            println!(
1719                "truth-mirror watch: reviewed {} commit(s)",
1720                report.reviewed.len()
1721            );
1722        }
1723        if !report.failed.is_empty() {
1724            eprintln!(
1725                "truth-mirror watch: skipped {} failed queue item(s)",
1726                report.failed.len()
1727            );
1728        }
1729        std::thread::sleep(interval);
1730    }
1731}
1732
1733/// `--until-empty` decision after a drain: given how long the queue has been
1734/// continuously empty (`empty_for_secs`) and the grace window, decide whether the
1735/// watcher should exit.
1736///
1737/// Pure so the grace-window policy is unit-testable without real clocks. `None`
1738/// means the queue is not empty (keep draining); `Some(elapsed)` carries how long
1739/// it has been empty so far.
1740#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1741pub enum UntilEmptyDecision {
1742    /// The queue has items, or the empty window has not yet spanned the grace
1743    /// period — keep polling.
1744    KeepWatching,
1745    /// The queue stayed empty through a full grace window — exit 0.
1746    Exit,
1747}
1748
1749pub fn until_empty_decision(empty_for_secs: Option<u64>, grace_secs: u64) -> UntilEmptyDecision {
1750    match empty_for_secs {
1751        Some(elapsed) if elapsed >= grace_secs => UntilEmptyDecision::Exit,
1752        _ => UntilEmptyDecision::KeepWatching,
1753    }
1754}
1755
1756#[derive(Clone, Debug, Eq, PartialEq)]
1757enum QueueFingerprint {
1758    Missing,
1759    Present {
1760        len: u64,
1761        modified: Option<SystemTime>,
1762    },
1763}
1764
1765#[derive(Clone, Debug, Default)]
1766struct QueueEmptyCache {
1767    fingerprint: Option<QueueFingerprint>,
1768    pending_count: Option<usize>,
1769}
1770
1771fn queue_fingerprint(queue: &ReviewQueue) -> Result<QueueFingerprint, ReviewerError> {
1772    match fs::metadata(queue.path()) {
1773        Ok(metadata) => Ok(QueueFingerprint::Present {
1774            len: metadata.len(),
1775            modified: metadata.modified().ok(),
1776        }),
1777        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(QueueFingerprint::Missing),
1778        Err(error) => Err(ReviewerError::QueueIo(error)),
1779    }
1780}
1781
1782fn queue_has_pending_cached(
1783    queue: &ReviewQueue,
1784    cache: &mut QueueEmptyCache,
1785) -> Result<bool, ReviewerError> {
1786    let fingerprint = queue_fingerprint(queue)?;
1787    if matches!(
1788        fingerprint,
1789        QueueFingerprint::Missing | QueueFingerprint::Present { len: 0, .. }
1790    ) {
1791        cache.fingerprint = Some(fingerprint);
1792        cache.pending_count = Some(0);
1793        return Ok(false);
1794    }
1795
1796    if let (true, Some(pending_count)) = (
1797        cache.fingerprint.as_ref() == Some(&fingerprint),
1798        cache.pending_count,
1799    ) {
1800        return Ok(pending_count > 0);
1801    }
1802
1803    let pending_count = queue.summary()?.pending_count;
1804    cache.fingerprint = Some(fingerprint);
1805    cache.pending_count = Some(pending_count);
1806    Ok(pending_count > 0)
1807}
1808
1809/// Drain the queue until it stays empty through a full grace window, then exit 0.
1810///
1811/// The watcher records its own identity in the single-flight lock on entry and
1812/// releases it on exit, so `ensure-watcher` can tell a live watcher from a dead
1813/// one. Late arrivals inside the grace window reset the empty timer, so a claim
1814/// enqueued a second before the watcher would have exited is still picked up.
1815#[allow(clippy::too_many_arguments)]
1816fn run_until_empty<R: ProcessRunner, L: MaterialLoader>(
1817    queue: &ReviewQueue,
1818    loader: &L,
1819    selection: &ReviewSelection,
1820    runner: &R,
1821    store: &LedgerStore,
1822    config: &config::TruthMirrorConfig,
1823    state_dir: &Path,
1824    grace_secs: u64,
1825    interval: std::time::Duration,
1826) -> Result<ExitCode> {
1827    // Adopt ownership of the single-flight lock under THIS process's identity, so
1828    // liveness probes point at the actual draining process (not the short-lived
1829    // `ensure-watcher` parent that spawned us).
1830    match crate::watcher::try_acquire_lock(state_dir) {
1831        Ok(crate::watcher::LockClaim::Acquired) => {}
1832        Ok(crate::watcher::LockClaim::HeldByLiveWatcher) => {
1833            eprintln!(
1834                "truth-mirror watch: watcher lock is already held; continuing without lock ownership"
1835            );
1836        }
1837        Err(error) => {
1838            eprintln!(
1839                "truth-mirror watch: failed to acquire watcher lock ({error}); continuing without lock ownership"
1840            );
1841        }
1842    }
1843
1844    let outcome = (|| -> Result<()> {
1845        let mut empty_since: Option<u64> = None;
1846        let mut queue_empty_cache = QueueEmptyCache::default();
1847        loop {
1848            let context = review_context(config);
1849            let report = drain_once(queue, loader, selection, &context, runner, store, config)?;
1850            if !report.reviewed.is_empty() {
1851                println!(
1852                    "truth-mirror watch: reviewed {} commit(s)",
1853                    report.reviewed.len()
1854                );
1855            }
1856            if !report.failed.is_empty() {
1857                eprintln!(
1858                    "truth-mirror watch: skipped {} failed queue item(s)",
1859                    report.failed.len()
1860                );
1861            }
1862
1863            let now = unix_now();
1864            if !queue_has_pending_cached(queue, &mut queue_empty_cache)? {
1865                let started = *empty_since.get_or_insert(now);
1866                let empty_for = now.saturating_sub(started);
1867                if until_empty_decision(Some(empty_for), grace_secs) == UntilEmptyDecision::Exit {
1868                    return Ok(());
1869                }
1870            } else {
1871                // A late arrival landed — reset the grace timer.
1872                empty_since = None;
1873            }
1874
1875            std::thread::sleep(interval);
1876        }
1877    })();
1878
1879    // Always release our lock, even on error, so a crashed drain never wedges the
1880    // next spawn (the staleness check would recover it anyway, but this is tidy).
1881    let _ = crate::watcher::release_lock_if_owned(state_dir);
1882    outcome?;
1883    Ok(ExitCode::SUCCESS)
1884}
1885
1886/// `ensure-watcher`: idempotent check-and-spawn of the queue-tied watcher.
1887///
1888/// Fast and quiet on success (safe to call from a git hook after every enqueue).
1889/// Prints one line only when it actually starts a watcher, so hook output stays
1890/// clean when a watcher already exists.
1891pub fn run_ensure_watcher_command(
1892    _args: cli::EnsureWatcherArgs,
1893    state_dir: &Path,
1894) -> Result<ExitCode> {
1895    match crate::watcher::ensure_watcher(state_dir)? {
1896        crate::watcher::LockClaim::Acquired => {
1897            println!("truth-mirror: watcher started");
1898        }
1899        crate::watcher::LockClaim::HeldByLiveWatcher => {
1900            tracing::debug!("ensure-watcher: live watcher already owns the state dir");
1901        }
1902    }
1903    Ok(ExitCode::SUCCESS)
1904}
1905
1906#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1907pub struct StrictGoalPolicy {
1908    pub stop_after_lies: u32,
1909    pub stop_after_fuckups: u32,
1910}
1911
1912#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1913pub struct StrictGoalCounters {
1914    pub lies_exposed: u32,
1915    pub fuckups_registered: u32,
1916}
1917
1918#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1919pub enum StrictGoalDecision {
1920    Continue,
1921    Stop { reason: StrictGoalStopReason },
1922}
1923
1924#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1925pub enum StrictGoalStopReason {
1926    LiesExposed,
1927    FuckupsRegistered,
1928}
1929
1930impl StrictGoalPolicy {
1931    pub fn decide(&self, counters: StrictGoalCounters) -> StrictGoalDecision {
1932        if self.stop_after_lies > 0 && counters.lies_exposed >= self.stop_after_lies {
1933            return StrictGoalDecision::Stop {
1934                reason: StrictGoalStopReason::LiesExposed,
1935            };
1936        }
1937
1938        if self.stop_after_fuckups > 0 && counters.fuckups_registered >= self.stop_after_fuckups {
1939            return StrictGoalDecision::Stop {
1940                reason: StrictGoalStopReason::FuckupsRegistered,
1941            };
1942        }
1943
1944        StrictGoalDecision::Continue
1945    }
1946}
1947
1948#[derive(Clone, Debug, Eq, PartialEq)]
1949pub struct StrictGoalOutcome {
1950    pub passes: u32,
1951    pub counters: StrictGoalCounters,
1952    /// `None` means the loop stopped at the `max_passes` ceiling rather than
1953    /// hitting a configured lie/fuckup threshold.
1954    pub stop_reason: Option<StrictGoalStopReason>,
1955    pub entries: Vec<LedgerEntry>,
1956}
1957
1958impl StrictGoalOutcome {
1959    pub fn stop_reason_suffix(&self) -> &'static str {
1960        match self.stop_reason {
1961            Some(StrictGoalStopReason::LiesExposed) => " (stopped: lies exposed)",
1962            Some(StrictGoalStopReason::FuckupsRegistered) => " (stopped: fuckups registered)",
1963            None => " (stopped: max passes)",
1964        }
1965    }
1966}
1967
1968/// Sic the adversarial reviewer on a commit in a loop, accumulating exposed lies
1969/// (REJECT verdicts) and registered fuckups (individual findings). Every pass is
1970/// recorded in the ledger. The loop stops when `policy` says the configured `N`
1971/// is reached, or when `max_passes` is hit so an honest agent still terminates.
1972#[allow(clippy::too_many_arguments)]
1973pub fn run_strict_goal_loop<R: ProcessRunner>(
1974    commit_sha: &str,
1975    claim: &Claim,
1976    diff: &str,
1977    context: &str,
1978    selection: &ReviewSelection,
1979    policy: StrictGoalPolicy,
1980    max_passes: u32,
1981    runner: &R,
1982    store: &LedgerStore,
1983) -> Result<StrictGoalOutcome, ReviewerError> {
1984    let ceiling = max_passes.max(1);
1985    let mut outcome = StrictGoalOutcome {
1986        passes: 0,
1987        counters: StrictGoalCounters {
1988            lies_exposed: 0,
1989            fuckups_registered: 0,
1990        },
1991        stop_reason: None,
1992        entries: Vec::new(),
1993    };
1994
1995    while outcome.passes < ceiling {
1996        let prompt = strict_goal_prompt(claim, diff, context, outcome.passes + 1, &outcome.entries);
1997        let request = selection.request_for(prompt);
1998        let plan = ReviewPlan::build(request.clone())?;
1999        let output = plan.run_with(&request.prompt, runner)?;
2000        ensure_process_success(&output)?;
2001        let verdict = ParsedVerdict::parse(&output.stdout)?;
2002
2003        let job = ReviewJob {
2004            commit_sha: commit_sha.to_owned(),
2005            claim: claim.clone(),
2006            diff: diff.to_owned(),
2007            context: context.to_owned(),
2008            request,
2009            strict: None,
2010            petition: None,
2011        };
2012        let entry = entry_from_verdict(&job, &plan, &verdict);
2013        store.append_entry(&entry)?;
2014        outcome.entries.push(entry);
2015
2016        outcome.passes += 1;
2017        if verdict.verdict == Verdict::Reject {
2018            outcome.counters.lies_exposed += 1;
2019        }
2020        outcome.counters.fuckups_registered = outcome
2021            .counters
2022            .fuckups_registered
2023            .saturating_add(u32::try_from(verdict.findings.len()).unwrap_or(u32::MAX));
2024
2025        if let StrictGoalDecision::Stop { reason } = policy.decide(outcome.counters) {
2026            outcome.stop_reason = Some(reason);
2027            break;
2028        }
2029    }
2030
2031    Ok(outcome)
2032}
2033
2034fn strict_goal_prompt(
2035    claim: &Claim,
2036    diff: &str,
2037    context: &str,
2038    pass: u32,
2039    prior: &[LedgerEntry],
2040) -> String {
2041    let prior_findings: Vec<String> = prior
2042        .iter()
2043        .flat_map(|entry| entry.findings.clone())
2044        .collect();
2045    let prior_block = if prior_findings.is_empty() {
2046        "(none)".to_owned()
2047    } else {
2048        prior_findings.join("\n")
2049    };
2050    format!(
2051        "{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{}",
2052        context_block(context),
2053        claim.to_line(),
2054        diff
2055    )
2056}
2057
2058pub fn run_review_command(
2059    args: cli::ReviewArgs,
2060    state_dir: &Path,
2061    config: &config::TruthMirrorConfig,
2062) -> Result<ExitCode> {
2063    if let Some(command) = args.command {
2064        return run_review_run_command(command, state_dir);
2065    }
2066
2067    let material = ReviewMaterial::load(
2068        &args,
2069        state_dir,
2070        &config.gates.to_policy().evidence_patterns,
2071    )?;
2072
2073    let mut selection = ReviewSelection::resolve(
2074        args.watched_agent,
2075        args.watched_model,
2076        args.reviewer_harness,
2077        args.reviewer_model,
2078        args.reviewer_effort,
2079        args.allow_same_model,
2080        config,
2081    )?;
2082
2083    if args.strict_two_pass {
2084        selection.strict = Some(ReviewSelection::resolve_arbiter(
2085            selection.watched_agent,
2086            args.arbiter_harness,
2087            args.arbiter_model,
2088            args.arbiter_effort,
2089            config,
2090        )?);
2091    }
2092    let store = LedgerStore::new(state_dir);
2093    let run_store = ReviewRunStore::new(state_dir);
2094    let context = review_context(config);
2095    let run = run_store.create_queued(&material.commit_sha, material.target_label.clone())?;
2096    run_store.mark_running(&run.id, "reviewing")?;
2097
2098    if args.strict_goal {
2099        let policy = config
2100            .strict
2101            .goal_policy(args.stop_after_lies, args.stop_after_fuckups);
2102        let max_passes = args.max_passes.unwrap_or(config.strict.max_passes);
2103        let outcome = match run_strict_goal_loop(
2104            &material.commit_sha,
2105            &material.claim,
2106            &material.diff,
2107            &context,
2108            &selection,
2109            policy,
2110            max_passes,
2111            &StdProcessRunner,
2112            &store,
2113        ) {
2114            Ok(outcome) => outcome,
2115            Err(error) => {
2116                let _ = run_store.mark_failed(&run.id, error.to_string());
2117                return Err(error.into());
2118            }
2119        };
2120        record_memory_skill_outcome(
2121            state_dir,
2122            config,
2123            &run.id,
2124            &material.commit_sha,
2125            "strict-goal",
2126            &outcome.entries,
2127        );
2128        run_store.mark_completed(&run.id, outcome.entries.len())?;
2129        println!(
2130            "truth-mirror strict-goal: run {}, {} pass(es), {} lie(s), {} fuckup(s){}",
2131            run.id,
2132            outcome.passes,
2133            outcome.counters.lies_exposed,
2134            outcome.counters.fuckups_registered,
2135            outcome.stop_reason_suffix(),
2136        );
2137        return Ok(ExitCode::SUCCESS);
2138    }
2139
2140    let prompt = first_pass_prompt(&material.claim, &material.diff, &context);
2141    let commit_sha = material.commit_sha.clone();
2142    let job = ReviewJob {
2143        commit_sha: material.commit_sha,
2144        claim: material.claim,
2145        diff: material.diff,
2146        context,
2147        request: selection.request_for(prompt),
2148        strict: selection.strict.clone(),
2149        petition: None,
2150    };
2151
2152    let execution = match execute_review_job(job, &StdProcessRunner, &store) {
2153        Ok(execution) => execution,
2154        Err(error) => {
2155            let _ = run_store.mark_failed(&run.id, error.to_string());
2156            return Err(error.into());
2157        }
2158    };
2159    record_memory_skill_outcome(
2160        state_dir,
2161        config,
2162        &run.id,
2163        &commit_sha,
2164        "manual-review",
2165        &execution.entries,
2166    );
2167    run_store.mark_completed(&run.id, execution.entries.len())?;
2168    println!(
2169        "truth-mirror review: run {}, wrote {} ledger entrie(s)",
2170        run.id,
2171        execution.entries.len()
2172    );
2173    Ok(ExitCode::SUCCESS)
2174}
2175
2176fn run_review_run_command(command: cli::ReviewCommand, state_dir: &Path) -> Result<ExitCode> {
2177    let runs = ReviewRunStore::new(state_dir);
2178    match command {
2179        cli::ReviewCommand::Status { run_id } => {
2180            if let Some(run_id) = run_id {
2181                print_run(&runs.read_reconciled(&run_id)?);
2182            } else {
2183                let all = runs.list_reconciled()?;
2184                if all.is_empty() {
2185                    println!("No review runs.");
2186                } else {
2187                    for run in all {
2188                        print_run_summary(&run);
2189                    }
2190                }
2191            }
2192        }
2193        cli::ReviewCommand::Result { run_id } => {
2194            let run = match run_id {
2195                Some(run_id) => runs.read(&run_id)?,
2196                None => runs.latest_result()?,
2197            };
2198            print_run(&run);
2199            print_run_ledger_entries(state_dir, &run)?;
2200        }
2201        cli::ReviewCommand::Cancel { run_id, force } => {
2202            let run = runs.cancel(&run_id, force)?;
2203            ReviewQueue::new(state_dir).remove_run_id(&run_id)?;
2204            match run.status {
2205                ReviewRunStatus::Failed => println!(
2206                    "reaped stale review run {} ({}): {}",
2207                    run.id,
2208                    run.commit_sha,
2209                    run.error.as_deref().unwrap_or("worker was not alive"),
2210                ),
2211                _ => println!("cancelled review run {} ({})", run.id, run.commit_sha),
2212            }
2213        }
2214    }
2215    Ok(ExitCode::SUCCESS)
2216}
2217
2218fn print_run_summary(run: &ReviewRun) {
2219    println!(
2220        "{} {} {} {} entries={} updated={}",
2221        run.id, run.status, run.commit_sha, run.phase, run.ledger_entries, run.updated_at_unix
2222    );
2223}
2224
2225fn print_run(run: &ReviewRun) {
2226    println!("run: {}", run.id);
2227    println!("status: {}", run.status);
2228    println!("commit: {}", run.commit_sha);
2229    println!("target: {}", run.target);
2230    println!("phase: {}", run.phase);
2231    println!("ledger_entries: {}", run.ledger_entries);
2232    if let Some(pid) = run.worker_pid {
2233        println!("worker_pid: {pid}");
2234    }
2235    println!("created_at_unix: {}", run.created_at_unix);
2236    println!("updated_at_unix: {}", run.updated_at_unix);
2237    if let Some(started) = run.started_at_unix {
2238        println!("started_at_unix: {started}");
2239    }
2240    if let Some(completed) = run.completed_at_unix {
2241        println!("completed_at_unix: {completed}");
2242    }
2243    if let Some(error) = &run.error {
2244        println!("error: {error}");
2245    }
2246    if let Some(checkpoint) = &run.entire_checkpoint {
2247        println!("entire_ref: {}", checkpoint.ref_name);
2248        println!("entire_sha: {}", checkpoint.object_sha);
2249    }
2250}
2251
2252fn print_run_ledger_entries(state_dir: &Path, run: &ReviewRun) -> Result<(), ReviewerError> {
2253    let store = LedgerStore::new(state_dir);
2254    let entries: Vec<LedgerEntry> = store
2255        .read_history()?
2256        .into_iter()
2257        .filter(|entry| entry.commit_sha == run.commit_sha)
2258        .collect();
2259    if entries.is_empty() {
2260        println!("ledger_entries: none");
2261        return Ok(());
2262    }
2263    println!("ledger_entries:");
2264    for entry in entries {
2265        println!(
2266            "- {} {} {} findings={}",
2267            entry.commit_sha,
2268            entry.verdict,
2269            entry.disposition,
2270            entry.findings.len()
2271        );
2272    }
2273    Ok(())
2274}
2275
2276#[derive(Clone, Debug, Eq, PartialEq)]
2277struct ReviewMaterial {
2278    commit_sha: String,
2279    target_label: String,
2280    claim: Claim,
2281    diff: String,
2282}
2283
2284impl ReviewMaterial {
2285    fn load(
2286        args: &cli::ReviewArgs,
2287        state_dir: &Path,
2288        evidence_patterns: &[String],
2289    ) -> Result<Self, ReviewerError> {
2290        let parse = |text: &str| {
2291            if evidence_patterns.is_empty() {
2292                Claim::parse(text)
2293            } else {
2294                Claim::parse_with(text, evidence_patterns)
2295            }
2296        };
2297
2298        let scope = if args.staged {
2299            ReviewScope::Staged
2300        } else {
2301            args.scope
2302        };
2303
2304        match scope {
2305            ReviewScope::Commit => {
2306                let target = args
2307                    .target
2308                    .clone()
2309                    .ok_or(ReviewerError::MissingReviewTarget)?;
2310                let sha = resolve_commit_target(&target)?;
2311                let message = git_output(["show", "--format=%B", "--no-patch", sha.as_str()])?;
2312                let diff = git_output(["show", "--format=", "--patch", sha.as_str()])?;
2313                let claim = parse(&message)?;
2314                Ok(Self {
2315                    commit_sha: sha.clone(),
2316                    target_label: format!("commit:{target}"),
2317                    claim,
2318                    diff,
2319                })
2320            }
2321            ReviewScope::Staged => Self::load_staged(state_dir, &parse),
2322            ReviewScope::Auto => {
2323                reject_target_with_scope(args)?;
2324                if working_tree_dirty()? {
2325                    Self::load_working_tree(state_dir, &parse)
2326                } else {
2327                    Self::load_branch(args.base.as_deref(), &parse)
2328                }
2329            }
2330            ReviewScope::WorkingTree => {
2331                reject_target_with_scope(args)?;
2332                Self::load_working_tree(state_dir, &parse)
2333            }
2334            ReviewScope::Branch => {
2335                reject_target_with_scope(args)?;
2336                Self::load_branch(args.base.as_deref(), &parse)
2337            }
2338        }
2339    }
2340
2341    fn load_staged<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
2342    where
2343        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2344    {
2345        let raw = git_output(["diff", "--cached"])?;
2346        let files = git_output(["diff", "--cached", "--name-only"])?;
2347        let diff = materialize_diff("staged", &raw, &files);
2348        let claim = parse(&read_claim_file(state_dir)?)?;
2349        Ok(Self {
2350            commit_sha: "STAGED".to_owned(),
2351            target_label: "staged".to_owned(),
2352            claim,
2353            diff,
2354        })
2355    }
2356
2357    fn load_working_tree<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
2358    where
2359        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2360    {
2361        let status = git_output(["status", "--porcelain"])?;
2362        let tracked = git_output(["diff", "HEAD", "--patch"])?;
2363        let files = git_output(["diff", "HEAD", "--name-only"])?;
2364        let untracked = untracked_file_context()?;
2365        let raw = format!(
2366            "WORKING TREE STATUS:\n{status}\n\nTRACKED DIFF AGAINST HEAD:\n{tracked}\n\nUNTRACKED FILES:\n{untracked}"
2367        );
2368        let diff = materialize_diff("working-tree", &raw, &files);
2369        let claim = parse(&read_claim_file(state_dir)?)?;
2370        Ok(Self {
2371            commit_sha: "WORKING_TREE".to_owned(),
2372            target_label: "working-tree".to_owned(),
2373            claim,
2374            diff,
2375        })
2376    }
2377
2378    fn load_branch<F>(base: Option<&str>, parse: &F) -> Result<Self, ReviewerError>
2379    where
2380        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2381    {
2382        let base = match base {
2383            Some(base) => base.to_owned(),
2384            None => default_branch_ref()?,
2385        };
2386        let merge_base = git_output_slice(&["merge-base", "HEAD", &base])?;
2387        let merge_base = merge_base.trim().to_owned();
2388        let range = format!("{merge_base}..HEAD");
2389        let message = git_output(["show", "--format=%B", "--no-patch", "HEAD"])?;
2390        let log = git_output_slice(&["log", "--oneline", &range])?;
2391        let stat = git_output_slice(&["diff", "--stat", &range])?;
2392        let raw_patch = git_output_slice(&["diff", "--patch", &range])?;
2393        let files = git_output_slice(&["diff", "--name-only", &range])?;
2394        let raw = format!(
2395            "BRANCH BASE: {base}\nMERGE BASE: {merge_base}\nCOMMITS:\n{log}\n\nDIFF STAT:\n{stat}\n\nDIFF:\n{raw_patch}"
2396        );
2397        let diff = materialize_diff(&format!("branch:{base}"), &raw, &files);
2398        let claim = parse(&message)?;
2399        Ok(Self {
2400            commit_sha: "HEAD".to_owned(),
2401            target_label: format!("branch:{base}"),
2402            claim,
2403            diff,
2404        })
2405    }
2406}
2407
2408fn resolve_commit_target(target: &str) -> Result<String, ReviewerError> {
2409    let rev = format!("{target}^{{commit}}");
2410    Ok(
2411        git_output_slice(&["rev-parse", "--verify", "--quiet", "--end-of-options", &rev])?
2412            .trim()
2413            .to_owned(),
2414    )
2415}
2416
2417fn reject_target_with_scope(args: &cli::ReviewArgs) -> Result<(), ReviewerError> {
2418    if let Some(target) = &args.target {
2419        return Err(ReviewerError::UnexpectedReviewTarget {
2420            scope: args.scope,
2421            target: target.clone(),
2422        });
2423    }
2424    Ok(())
2425}
2426
2427fn read_claim_file(state_dir: &Path) -> Result<String, ReviewerError> {
2428    let claim_path = state_dir.join("claim.txt");
2429    fs::read_to_string(&claim_path).map_err(|source| ReviewerError::ClaimFileRead {
2430        path: claim_path,
2431        source,
2432    })
2433}
2434
2435fn working_tree_dirty() -> Result<bool, ReviewerError> {
2436    Ok(!git_output(["status", "--porcelain"])?.trim().is_empty())
2437}
2438
2439fn default_branch_ref() -> Result<String, ReviewerError> {
2440    if let Ok(symbolic) = git_output([
2441        "symbolic-ref",
2442        "--quiet",
2443        "--short",
2444        "refs/remotes/origin/HEAD",
2445    ]) {
2446        let trimmed = symbolic.trim();
2447        if !trimmed.is_empty() {
2448            return Ok(trimmed.to_owned());
2449        }
2450    }
2451
2452    for candidate in [
2453        "origin/main",
2454        "origin/master",
2455        "origin/trunk",
2456        "main",
2457        "master",
2458        "trunk",
2459    ] {
2460        if git_output_slice(&["rev-parse", "--verify", "--quiet", candidate]).is_ok() {
2461            return Ok(candidate.to_owned());
2462        }
2463    }
2464
2465    Err(ReviewerError::DefaultBranchNotFound)
2466}
2467
2468fn materialize_diff(label: &str, raw: &str, files: &str) -> String {
2469    let file_list: Vec<&str> = files
2470        .lines()
2471        .filter(|line| !line.trim().is_empty())
2472        .collect();
2473    let bytes = raw.len();
2474    if bytes <= MAX_INLINE_DIFF_BYTES && file_list.len() <= MAX_INLINE_DIFF_FILES {
2475        return raw.to_owned();
2476    }
2477
2478    format!(
2479        "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.",
2480        file_list.len(),
2481        if file_list.is_empty() {
2482            "(none)".to_owned()
2483        } else {
2484            file_list.join("\n")
2485        }
2486    )
2487}
2488
2489fn is_full_git_sha(value: &str) -> bool {
2490    matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2491}
2492
2493fn untracked_file_context() -> Result<String, ReviewerError> {
2494    let files = git_output(["ls-files", "--others", "--exclude-standard"])?;
2495    let mut output = String::new();
2496    for file in files.lines().filter(|line| !line.trim().is_empty()) {
2497        let path = Path::new(file);
2498        let metadata = match fs::metadata(path) {
2499            Ok(metadata) => metadata,
2500            Err(_) => continue,
2501        };
2502        if !metadata.is_file() {
2503            continue;
2504        }
2505        if metadata.len() > MAX_UNTRACKED_FILE_BYTES {
2506            output.push_str(&format!(
2507                "\n--- {file} omitted: {} bytes exceeds {MAX_UNTRACKED_FILE_BYTES} byte inline limit ---\n",
2508                metadata.len()
2509            ));
2510            continue;
2511        }
2512        let bytes = match fs::read(path) {
2513            Ok(bytes) => bytes,
2514            Err(_) => continue,
2515        };
2516        if bytes.contains(&0) {
2517            output.push_str(&format!("\n--- {file} omitted: binary file ---\n"));
2518            continue;
2519        }
2520        output.push_str(&format!(
2521            "\n--- {file} ---\n{}",
2522            String::from_utf8_lossy(&bytes)
2523        ));
2524    }
2525
2526    if output.is_empty() {
2527        Ok("(none)".to_owned())
2528    } else {
2529        Ok(output)
2530    }
2531}
2532
2533#[derive(Debug, Error)]
2534pub enum ReviewerError {
2535    #[error("missing {role} model")]
2536    MissingModel { role: String },
2537    #[error(
2538        "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
2539    )]
2540    SameModelWithoutWaiver {
2541        watched_model: String,
2542        reviewer_model: String,
2543    },
2544    #[error("strict arbiter model must differ from watched and first reviewer models")]
2545    StrictArbiterModelNotDistinct,
2546    #[error("no adversarial pair configured for writer harness {writer:?}")]
2547    NoPairForWriter { writer: String },
2548    #[error(
2549        "strict review requires an arbiter (pair.arbiter or --arbiter-harness/--arbiter-model)"
2550    )]
2551    MissingArbiter,
2552    #[error(
2553        "--{role}-harness={harness:?} was overridden without a matching --{role}-model; the pair's model is for a different harness"
2554    )]
2555    OverrideNeedsModel { role: String, harness: String },
2556    #[error("custom reviewer harness requires explicit command configuration")]
2557    UnsupportedCustomHarness,
2558    #[error("unknown watched agent {value:?}")]
2559    UnknownAgent { value: String },
2560    #[error("unknown reviewer harness {value:?}")]
2561    UnknownHarness { value: String },
2562    #[error("missing review target")]
2563    MissingReviewTarget,
2564    #[error("--scope={scope:?} does not accept positional target {target:?}")]
2565    UnexpectedReviewTarget { scope: ReviewScope, target: String },
2566    #[error("could not determine default branch; pass --base explicitly")]
2567    DefaultBranchNotFound,
2568    #[error("failed to read staged claim file {path}: {source}")]
2569    ClaimFileRead {
2570        path: PathBuf,
2571        #[source]
2572        source: io::Error,
2573    },
2574    #[error("reviewer output was not valid structured JSON verdict: {source}: {output:?}")]
2575    VerdictJson {
2576        source: serde_json::Error,
2577        output: String,
2578    },
2579    #[error("reviewer structured verdict violated schema: {message}")]
2580    VerdictSchema { message: String },
2581    #[error("reviewer process exited with status {status:?}: {stderr}")]
2582    ReviewerProcessFailed { status: Option<i32>, stderr: String },
2583    #[error("git command failed: git {args:?}: {stderr}")]
2584    GitFailed { args: Vec<String>, stderr: String },
2585    #[error("failed to spawn git command: {0}")]
2586    GitSpawn(io::Error),
2587    #[error("failed to spawn reviewer process: {0}")]
2588    Spawn(io::Error),
2589    #[error("failed to open reviewer stdin pipe")]
2590    MissingStdinPipe,
2591    #[error("failed to write reviewer prompt: {0}")]
2592    WritePrompt(io::Error),
2593    #[error("failed to wait for reviewer process: {0}")]
2594    Wait(io::Error),
2595    #[error("review queue IO failed: {0}")]
2596    QueueIo(io::Error),
2597    #[error("review queue JSON failed: {0}")]
2598    QueueJson(serde_json::Error),
2599    #[error("review run IO failed: {0}")]
2600    RunIo(io::Error),
2601    #[error("review run JSON failed: {0}")]
2602    RunJson(serde_json::Error),
2603    #[error("review run not found: {id}")]
2604    ReviewRunNotFound { id: String },
2605    #[error("no review runs found")]
2606    NoReviewRuns,
2607    #[error("cannot cancel review run {id} with status {status}; it has already finished")]
2608    CannotCancelReview { id: String, status: ReviewRunStatus },
2609    #[error(
2610        "review run {id} is still running (worker pid {pid} is alive); pass --force to kill it"
2611    )]
2612    ReviewRunStillAlive { id: String, pid: u32 },
2613    #[error(
2614        "review run {id} is running but records no worker pid; pass --force to reap it if it is stuck"
2615    )]
2616    ReviewRunLivenessUnknown { id: String },
2617    #[error("failed to spawn kill for stale worker: {0}")]
2618    KillWorker(io::Error),
2619    #[error("failed to kill worker process {pid}")]
2620    KillWorkerFailed { pid: u32 },
2621    #[error(transparent)]
2622    Claim(#[from] crate::claim::ClaimError),
2623    #[error(transparent)]
2624    Ledger(#[from] crate::ledger::LedgerError),
2625}
2626
2627const 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.
2628
2629Attack 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.
2630
2631GREP 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.
2632
2633Return valid JSON only. Do not wrap it in Markdown. The schema is:
2634{
2635  "verdict": "PASS" | "REJECT" | "FLAG",
2636  "summary": "one concise sentence explaining why the claim passes or fails",
2637  "findings": [
2638    {
2639      "severity": "critical" | "high" | "medium" | "low",
2640      "title": "short defect title",
2641      "body": "what can go wrong, why this code is vulnerable, and what evidence proves it",
2642      "file": "repo-relative file path",
2643      "line_start": 1,
2644      "line_end": 1,
2645      "confidence": 0,
2646      "recommendation": "concrete change required"
2647    }
2648  ],
2649  "next_steps": ["short concrete follow-up commands or edits"],
2650  "memory_skill": {
2651    "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2652    "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2653    "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2654  }
2655}
2656
2657For "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.
2658
2659Verdict semantics:
2660- "PASS": the claim is substantiated AND there are no findings. Never pair PASS with findings.
2661- "REJECT": at least one finding is materially false / unverified / unaddressed. Must include at least one finding.
2662- "FLAG": the claim substantively holds but the reviewer wants to surface non-blocking process / evidence / provenance debt alongside the verdict. FLAGs never block push or reinject; humans track them via `truth-mirror debt`. Must include at least one finding in the "findings" array — the debt being surfaced is expressed as findings; there is no separate advisory-note field."#;
2663
2664fn context_block(context: &str) -> String {
2665    if context.trim().is_empty() {
2666        String::new()
2667    } else {
2668        format!("\n\n{context}")
2669    }
2670}
2671
2672fn first_pass_prompt(claim: &Claim, diff: &str, context: &str) -> String {
2673    format!(
2674        "{ADVERSARIAL_PREAMBLE}{}\n\nCLAIM:\n{}\n\nDIFF:\n{}",
2675        context_block(context),
2676        claim.to_line(),
2677        diff
2678    )
2679}
2680
2681const PETITION_PREAMBLE: &str = r#"You are an ADVERSARIAL petition reviewer. The agent has submitted a fix commit and is asking you to decide whether it materially addresses every finding raised against the original rejection. Your job is not to re-evaluate the original claim from scratch; it is to read each original finding, inspect the fix, and judge whether the fix actually addresses it (instance) and the broader class the finding represents.
2682
2683Return valid JSON only. Do not wrap it in Markdown. The schema is:
2684{
2685  "verdict": "PASS" | "REJECT" | "FLAG",
2686  "summary": "one concise sentence explaining whether the fix materially addresses each finding",
2687  "findings": [
2688    {
2689      "severity": "critical" | "high" | "medium" | "low",
2690      "title": "short defect title",
2691      "body": "what still fails after the fix, why this code is still vulnerable, and what evidence proves it",
2692      "file": "repo-relative file path",
2693      "line_start": 1,
2694      "line_end": 1,
2695      "confidence": 0,
2696      "recommendation": "concrete change required"
2697    }
2698  ],
2699  "next_steps": ["short concrete follow-up commands or edits"],
2700  "memory_skill": {
2701    "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2702    "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2703    "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2704  }
2705}
2706
2707Verdict semantics:
2708- "PASS" means the fix materially addresses every original finding. Return PASS only when findings is empty.
2709- "REJECT" means at least one finding is still materially unaddressed by the fix.
2710- "FLAG" means the fix materially addresses the findings but you want to surface process / evidence / provenance debt alongside the acceptance. Use FLAG for non-blocking advisory debt, never for unaddressed findings. Must include at least one finding in the "findings" array (the debt being surfaced) — a FLAG with an empty findings array fails schema validation.
2711
2712For "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."#;
2713
2714fn petition_prompt(petition: &PetitionContext, claim: &Claim, diff: &str, context: &str) -> String {
2715    let findings_block = if petition.original_structured_findings.is_empty() {
2716        petition.original_findings.join("\n")
2717    } else {
2718        petition
2719            .original_structured_findings
2720            .iter()
2721            .map(StructuredFinding::display_line)
2722            .collect::<Vec<_>>()
2723            .join("\n")
2724    };
2725    format!(
2726        "{PETITION_PREAMBLE}{}\n\nPETITION:\n- original rejection SHA: {}\n- fix commit SHA: {}\n- petition attempts so far: {}\n- original reviewer model: {}\n\nORIGINAL CLAIM:\n{}\n\nORIGINAL SUMMARY:\n{}\n\nORIGINAL FINDINGS:\n{}\n\nFIX COMMIT DIFF:\n{}\n\nCLAIM (for ledger):\n{}",
2727        context_block(context),
2728        petition.original_sha,
2729        petition.fix_sha,
2730        petition.attempts_so_far,
2731        petition.original_reviewer_model,
2732        petition.original_claim,
2733        petition.original_summary,
2734        if findings_block.trim().is_empty() {
2735            "(none recorded)".to_owned()
2736        } else {
2737            findings_block
2738        },
2739        diff,
2740        claim.to_line(),
2741    )
2742}
2743
2744fn strict_second_pass_prompt(job: &ReviewJob, first_output: &str) -> String {
2745    // A strict pass over a PETITION must stay on the petition question — the
2746    // generic completeness prompt carries the fix commit's claim/diff but not
2747    // the original rejection's findings, so the arbiter would end up judging
2748    // a first review whose question it never saw.
2749    if let Some(petition) = &job.petition {
2750        let petition_question = petition_prompt(petition, &job.claim, &job.diff, &job.context);
2751        return format!(
2752            "{petition_question}\n\nSTRICT SECOND PASS (COMPLETENESS CRITIC): the first petition reviewer ACCEPTED that the fix materially addresses each original finding. Assume it verified some findings but not ALL of them. Re-check every original finding against the fix diff and prove the first reviewer INCOMPLETE.\n\nFIRST REVIEW:\n{first_output}"
2753        );
2754    }
2755    format!(
2756        "{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{}",
2757        context_block(&job.context),
2758        job.claim.to_line(),
2759        first_output,
2760        job.diff
2761    )
2762}
2763
2764fn entry_from_verdict(job: &ReviewJob, plan: &ReviewPlan, verdict: &ParsedVerdict) -> LedgerEntry {
2765    let mut entry = LedgerEntry::new(
2766        job.commit_sha.clone(),
2767        verdict.verdict,
2768        job.claim.to_line(),
2769        job.claim
2770            .evidence
2771            .iter()
2772            .map(EvidenceRef::as_str)
2773            .map(str::to_owned)
2774            .collect(),
2775        plan.reviewer_config(),
2776        verdict.findings.clone(),
2777    )
2778    .with_structured_review(
2779        verdict.summary.clone(),
2780        verdict.structured_findings.clone(),
2781        verdict.next_steps.clone(),
2782        verdict.raw.clone(),
2783    )
2784    .with_memory_skill_classification(verdict.memory_skill_classification.clone());
2785    if let Some(petition) = &job.petition {
2786        entry.petition_for = Some(petition.original_sha.clone());
2787        // `attempts_so_far` already includes the in-flight attempt — the CLI
2788        // counted it when it enqueued the petition (see `resolve::run_petition`).
2789        // Adding 1 here would double-count every attempt across CLI + watcher.
2790        entry.petition_attempts = petition.attempts_so_far;
2791    }
2792    entry
2793}
2794
2795fn ensure_process_success(output: &ProcessOutput) -> Result<(), ReviewerError> {
2796    if output.status_code == Some(0) {
2797        return Ok(());
2798    }
2799
2800    Err(ReviewerError::ReviewerProcessFailed {
2801        status: output.status_code,
2802        stderr: output.stderr.clone(),
2803    })
2804}
2805
2806fn validate_strict_arbiter(
2807    request: &ReviewRequest,
2808    strict: &StrictReviewConfig,
2809) -> Result<(), ReviewerError> {
2810    let arbiter = normalized_model(&strict.arbiter_model);
2811    if arbiter == normalized_model(&request.watched_model)
2812        || arbiter == normalized_model(&request.reviewer_model)
2813    {
2814        return Err(ReviewerError::StrictArbiterModelNotDistinct);
2815    }
2816    Ok(())
2817}
2818
2819fn validate_model_present(role: &str, model: &str) -> Result<(), ReviewerError> {
2820    if model.trim().is_empty() {
2821        return Err(ReviewerError::MissingModel {
2822            role: role.to_owned(),
2823        });
2824    }
2825    Ok(())
2826}
2827
2828fn git_output<const N: usize>(args: [&str; N]) -> Result<String, ReviewerError> {
2829    git_output_slice(&args)
2830}
2831
2832fn git_output_slice(args: &[&str]) -> Result<String, ReviewerError> {
2833    let output = Command::new("git")
2834        .args(args)
2835        .output()
2836        .map_err(ReviewerError::GitSpawn)?;
2837    if !output.status.success() {
2838        return Err(ReviewerError::GitFailed {
2839            args: args.iter().map(|arg| (*arg).to_owned()).collect(),
2840            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2841        });
2842    }
2843
2844    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
2845}
2846
2847fn agent_from_slug(value: &str) -> Result<Agent, ReviewerError> {
2848    match value.trim().to_ascii_lowercase().as_str() {
2849        "claude" => Ok(Agent::Claude),
2850        "codex" => Ok(Agent::Codex),
2851        "pi" => Ok(Agent::Pi),
2852        "grok" => Ok(Agent::Grok),
2853        _ => Err(ReviewerError::UnknownAgent {
2854            value: value.to_owned(),
2855        }),
2856    }
2857}
2858
2859fn harness_from_slug(value: &str) -> Result<ReviewerHarness, ReviewerError> {
2860    match value.trim().to_ascii_lowercase().as_str() {
2861        "claude" => Ok(ReviewerHarness::Claude),
2862        "codex" => Ok(ReviewerHarness::Codex),
2863        "pi" => Ok(ReviewerHarness::Pi),
2864        "gemini" => Ok(ReviewerHarness::Gemini),
2865        "opencode" => Ok(ReviewerHarness::Opencode),
2866        "custom" => Ok(ReviewerHarness::Custom),
2867        _ => Err(ReviewerError::UnknownHarness {
2868            value: value.to_owned(),
2869        }),
2870    }
2871}
2872
2873fn harness_slug(harness: ReviewerHarness) -> &'static str {
2874    match harness {
2875        ReviewerHarness::Claude => "claude",
2876        ReviewerHarness::Codex => "codex",
2877        ReviewerHarness::Pi => "pi",
2878        ReviewerHarness::Gemini => "gemini",
2879        ReviewerHarness::Opencode => "opencode",
2880        ReviewerHarness::Custom => "custom",
2881    }
2882}
2883
2884/// Normalise a model identifier for opposition comparisons.
2885///
2886/// Delegates to [`crate::config::normalized_model`], which is the single
2887/// authoritative implementation shared by config-time validation and the
2888/// runtime same-model guard here (R6 dedup).
2889fn normalized_model(model: &str) -> String {
2890    config::normalized_model(model)
2891}
2892
2893fn generate_run_id(commit_sha: &str) -> String {
2894    let nanos = SystemTime::now()
2895        .duration_since(UNIX_EPOCH)
2896        .map_or(0, |duration| duration.as_nanos());
2897    let short_sha: String = commit_sha
2898        .chars()
2899        .filter(|character| character.is_ascii_alphanumeric())
2900        .take(12)
2901        .collect();
2902    if short_sha.is_empty() {
2903        format!("{nanos}-{}", std::process::id())
2904    } else {
2905        format!("{nanos}-{}-{short_sha}", std::process::id())
2906    }
2907}
2908
2909#[cfg(test)]
2910mod tests {
2911    use std::{cell::RefCell, collections::VecDeque, fs, process::Command};
2912
2913    use super::normalized_model;
2914
2915    use proptest::prelude::*;
2916
2917    use super::{
2918        InvocationPlan, MaterialLoader, ParsedVerdict, ProcessOutput, ProcessRunner,
2919        PromptDelivery, ReviewJob, ReviewPlan, ReviewQueue, ReviewRequest, ReviewRun,
2920        ReviewRunStatus, ReviewRunStore, ReviewSelection, ReviewerError, StrictGoalCounters,
2921        StrictGoalDecision, StrictGoalPolicy, StrictGoalStopReason, StrictReviewConfig,
2922        UntilEmptyDecision, drain_once, execute_review_job, is_full_git_sha,
2923        run_review_run_command, run_strict_goal_loop, until_empty_decision,
2924    };
2925    use crate::{
2926        claim::{Claim, EvidenceRef},
2927        cli::{Agent, ReviewerHarness},
2928        config::{Effort, TruthMirrorConfig},
2929        ledger::{FindingSeverity, LedgerStore, StructuredFinding, Verdict},
2930        watcher::pid_is_alive,
2931    };
2932
2933    fn pass_json() -> String {
2934        serde_json::json!({
2935            "verdict": "PASS",
2936            "summary": "The claim is substantiated by the diff and evidence.",
2937            "findings": [],
2938            "next_steps": [],
2939            "memory_skill": {
2940                "kind": "how_to_skill",
2941                "learning_source": "run reusable review workflow",
2942                "reasoning": "The passing claim describes a reusable verified procedure."
2943            }
2944        })
2945        .to_string()
2946    }
2947
2948    fn reject_json(title: &str) -> String {
2949        serde_json::json!({
2950            "verdict": "REJECT",
2951            "summary": "The claim is not substantiated.",
2952            "findings": [{
2953                "severity": "high",
2954                "title": title,
2955                "body": "The cited evidence does not prove the claimed behavior.",
2956                "file": "src/lib.rs",
2957                "line_start": 1,
2958                "line_end": 1,
2959                "confidence": 95,
2960                "recommendation": "Provide executable evidence that proves the claim."
2961            }],
2962            "next_steps": ["Run the relevant verification command."],
2963            "memory_skill": {
2964                "kind": "anti_pattern_skill",
2965                "learning_source": title,
2966                "reasoning": "The rejection identifies a reusable false-claim failure class."
2967            }
2968        })
2969        .to_string()
2970    }
2971
2972    #[test]
2973    fn same_harness_different_model_is_valid() {
2974        let request = ReviewRequest::new(
2975            Agent::Codex,
2976            "gpt-5.4",
2977            ReviewerHarness::Codex,
2978            "gpt-5.5",
2979            false,
2980            "review this",
2981        );
2982
2983        let plan = ReviewPlan::build(request).unwrap();
2984
2985        assert_eq!(plan.watched_agent, Agent::Codex);
2986        assert_eq!(plan.reviewer_harness, ReviewerHarness::Codex);
2987        assert_eq!(plan.invocation.program, "codex");
2988    }
2989
2990    #[test]
2991    fn same_model_is_blocked_by_default() {
2992        let request = ReviewRequest::new(
2993            Agent::Codex,
2994            " GPT-5.5 ",
2995            ReviewerHarness::Claude,
2996            "gpt-5.5",
2997            false,
2998            "review this",
2999        );
3000
3001        let error = ReviewPlan::build(request).unwrap_err();
3002
3003        assert!(matches!(
3004            error,
3005            ReviewerError::SameModelWithoutWaiver { .. }
3006        ));
3007    }
3008
3009    #[test]
3010    fn allow_same_model_override_is_deliberate() {
3011        let request = ReviewRequest::new(
3012            Agent::Codex,
3013            "gpt-5.5",
3014            ReviewerHarness::Codex,
3015            "gpt-5.5",
3016            true,
3017            "review this",
3018        );
3019
3020        let plan = ReviewPlan::build(request).unwrap();
3021
3022        assert!(plan.allow_same_model);
3023        assert_eq!(plan.reviewer_model, "gpt-5.5");
3024    }
3025
3026    #[test]
3027    fn provider_mapping_uses_verified_prompt_shapes_and_effort() {
3028        let codex =
3029            InvocationPlan::for_harness(ReviewerHarness::Codex, "gpt-5.5", Effort::Xhigh).unwrap();
3030        assert_eq!(codex.program, "codex");
3031        assert_eq!(
3032            codex.args_for_prompt("prompt"),
3033            [
3034                "exec",
3035                "-m",
3036                "gpt-5.5",
3037                "-c",
3038                "model_reasoning_effort=xhigh",
3039                "prompt"
3040            ]
3041        );
3042
3043        let claude =
3044            InvocationPlan::for_harness(ReviewerHarness::Claude, "opus", Effort::High).unwrap();
3045        assert_eq!(claude.program, "claude");
3046        assert_eq!(claude.prompt_delivery, PromptDelivery::Stdin);
3047        assert_eq!(
3048            claude.args_for_prompt("prompt"),
3049            ["--print", "--model", "opus", "--effort", "high"]
3050        );
3051
3052        let gemini =
3053            InvocationPlan::for_harness(ReviewerHarness::Gemini, "gemini-pro", Effort::Xhigh)
3054                .unwrap();
3055        assert_eq!(
3056            gemini.args_for_prompt("prompt"),
3057            ["-m", "gemini-pro", "-p", "prompt"]
3058        );
3059
3060        let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "openai/gpt-5.5", Effort::Xhigh)
3061            .unwrap();
3062        assert_eq!(pi.prompt_delivery, PromptDelivery::Stdin);
3063        assert_eq!(
3064            pi.args_for_prompt("prompt"),
3065            [
3066                "--model",
3067                "openai/gpt-5.5",
3068                "--thinking",
3069                "xhigh",
3070                "--tools",
3071                "read,grep,find,ls",
3072                "-p"
3073            ]
3074        );
3075    }
3076
3077    #[test]
3078    fn custom_harness_requires_explicit_configuration() {
3079        let error = InvocationPlan::for_harness(ReviewerHarness::Custom, "model", Effort::Xhigh)
3080            .unwrap_err();
3081
3082        assert!(matches!(error, ReviewerError::UnsupportedCustomHarness));
3083    }
3084
3085    #[test]
3086    fn effort_maps_to_each_harness_flag() {
3087        for effort in [
3088            Effort::Minimal,
3089            Effort::Low,
3090            Effort::Medium,
3091            Effort::High,
3092            Effort::Xhigh,
3093        ] {
3094            let e = effort.as_str();
3095
3096            let codex = InvocationPlan::for_harness(ReviewerHarness::Codex, "m", effort).unwrap();
3097            assert!(codex.args.contains(&format!("model_reasoning_effort={e}")));
3098
3099            let claude = InvocationPlan::for_harness(ReviewerHarness::Claude, "m", effort).unwrap();
3100            let claude_idx = claude.args.iter().position(|a| a == "--effort").unwrap();
3101            // Claude has no `minimal`; it clamps to a valid level (`low`).
3102            assert_eq!(claude.args[claude_idx + 1], effort.claude_value());
3103            assert_ne!(claude.args[claude_idx + 1], "minimal");
3104
3105            let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "m", effort).unwrap();
3106            let pi_idx = pi.args.iter().position(|a| a == "--thinking").unwrap();
3107            assert_eq!(pi.args[pi_idx + 1], e);
3108        }
3109    }
3110
3111    #[test]
3112    fn resolve_picks_configured_reviewer_for_every_writer() {
3113        let config = crate::config::TruthMirrorConfig::default();
3114
3115        let cases = [
3116            (Agent::Codex, ReviewerHarness::Claude, "claude-opus-4-8"),
3117            (Agent::Claude, ReviewerHarness::Codex, "gpt-5.5"),
3118            (Agent::Pi, ReviewerHarness::Codex, "gpt-5.5"),
3119            (Agent::Grok, ReviewerHarness::Claude, "claude-opus-4-8"),
3120        ];
3121
3122        for (writer, reviewer_harness, reviewer_model) in cases {
3123            let selection =
3124                ReviewSelection::resolve(Some(writer), None, None, None, None, false, &config)
3125                    .unwrap();
3126
3127            assert_eq!(selection.reviewer_harness, reviewer_harness);
3128            assert_eq!(selection.reviewer_model, reviewer_model);
3129            assert_eq!(selection.reviewer_effort, Effort::Xhigh);
3130        }
3131    }
3132
3133    #[test]
3134    fn overriding_reviewer_harness_without_model_is_rejected() {
3135        // codex's default pair reviewer is claude; overriding harness to pi with no
3136        // model would pair the pi harness with a claude model string.
3137        let config = crate::config::TruthMirrorConfig::default();
3138        let error = ReviewSelection::resolve(
3139            Some(Agent::Codex),
3140            None,
3141            Some(ReviewerHarness::Pi),
3142            None,
3143            None,
3144            false,
3145            &config,
3146        )
3147        .unwrap_err();
3148
3149        assert!(matches!(error, ReviewerError::OverrideNeedsModel { .. }));
3150    }
3151
3152    #[test]
3153    fn overriding_reviewer_harness_matching_pair_is_ok() {
3154        let config = crate::config::TruthMirrorConfig::default();
3155        let selection = ReviewSelection::resolve(
3156            Some(Agent::Codex),
3157            None,
3158            Some(ReviewerHarness::Claude),
3159            None,
3160            None,
3161            false,
3162            &config,
3163        )
3164        .unwrap();
3165
3166        assert_eq!(selection.reviewer_harness, ReviewerHarness::Claude);
3167        assert_eq!(selection.reviewer_model, "claude-opus-4-8");
3168    }
3169
3170    #[test]
3171    fn config_allow_same_model_waives_opposition() {
3172        let config = crate::config::TruthMirrorConfig {
3173            allow_same_model: true,
3174            ..crate::config::TruthMirrorConfig::default()
3175        };
3176
3177        let selection = ReviewSelection::resolve(
3178            Some(Agent::Codex),
3179            Some("gpt-5.5".to_owned()),
3180            Some(ReviewerHarness::Codex),
3181            Some("gpt-5.5".to_owned()),
3182            None,
3183            false, // CLI flag not set — the config waiver must carry it
3184            &config,
3185        )
3186        .unwrap();
3187
3188        assert!(selection.allow_same_model);
3189        // Same watched+reviewer model builds because the config waiver applies.
3190        assert!(ReviewPlan::build(selection.request_for("review".to_owned())).is_ok());
3191    }
3192
3193    #[test]
3194    fn full_git_sha_accepts_sha1_and_sha256_lengths() {
3195        assert!(is_full_git_sha(&"a".repeat(40)));
3196        assert!(is_full_git_sha(&"b".repeat(64)));
3197        assert!(!is_full_git_sha(&"c".repeat(39)));
3198        assert!(!is_full_git_sha(&"g".repeat(40)));
3199    }
3200
3201    #[test]
3202    fn resolve_arbiter_uses_pair_when_cli_absent() {
3203        let config = crate::config::TruthMirrorConfig::default();
3204        let arbiter =
3205            ReviewSelection::resolve_arbiter(Agent::Codex, None, None, None, &config).unwrap();
3206
3207        assert_eq!(arbiter.arbiter_harness, ReviewerHarness::Pi);
3208        assert_eq!(arbiter.arbiter_effort, Effort::Xhigh);
3209    }
3210
3211    #[test]
3212    fn first_pass_prompt_is_adversarial_and_injects_context() {
3213        let prompt = super::first_pass_prompt(
3214            &claim(),
3215            "THE_DIFF_BODY",
3216            "INVIOLABLE CONSTRAINTS: never fake tests",
3217        );
3218
3219        assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
3220        assert!(prompt.contains("default to REJECT"));
3221        assert!(prompt.contains("INVIOLABLE CONSTRAINTS: never fake tests"));
3222        assert!(prompt.contains("THE_DIFF_BODY"));
3223        // Class-generalized review: grep the class, not the instance.
3224        assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
3225        assert!(prompt.contains("\"severity\""));
3226        assert!(prompt.contains("\"recommendation\""));
3227        assert!(prompt.contains("\"memory_skill\""));
3228        assert!(prompt.contains("\"anti_pattern_skill\""));
3229    }
3230
3231    #[test]
3232    fn strict_second_pass_is_a_completeness_critic() {
3233        let job = review_job(true);
3234        let first_output = pass_json();
3235        let prompt = super::strict_second_pass_prompt(&job, &first_output);
3236
3237        assert!(prompt.contains("COMPLETENESS CRITIC"));
3238        assert!(prompt.contains("generalize"));
3239        // Inherits the class-sweep preamble.
3240        assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
3241    }
3242
3243    #[test]
3244    fn prompt_omits_context_block_when_empty() {
3245        let prompt = super::first_pass_prompt(&claim(), "d", "");
3246        // No dangling empty context header.
3247        assert!(!prompt.contains("INVIOLABLE CONSTRAINTS"));
3248        assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
3249    }
3250
3251    fn sample_petition(attempts_so_far: u32) -> super::PetitionContext {
3252        super::PetitionContext {
3253            original_sha: "abc123".to_owned(),
3254            fix_sha: "def456".to_owned(),
3255            original_claim: "CLAIM: original | verified: cargo test | evidence: tests:cargo-test"
3256                .to_owned(),
3257            original_summary: "Original rejection summary.".to_owned(),
3258            original_findings: vec!["evidence too thin".to_owned()],
3259            original_structured_findings: vec![StructuredFinding {
3260                severity: FindingSeverity::High,
3261                title: "thin evidence".to_owned(),
3262                body: "Evidence pointer does not prove the claim.".to_owned(),
3263                file: "src/lib.rs".to_owned(),
3264                line_start: 1,
3265                line_end: 2,
3266                confidence: 90,
3267                recommendation: "Add executable evidence.".to_owned(),
3268            }],
3269            original_reviewer_model: "claude-opus-4-1".to_owned(),
3270            attempts_so_far,
3271        }
3272    }
3273
3274    #[test]
3275    fn petition_prompt_includes_original_findings_fix_sha_and_attempts() {
3276        let prompt = super::petition_prompt(&sample_petition(1), &claim(), "DIFF BODY", "");
3277
3278        assert!(prompt.contains("PETITION"));
3279        assert!(prompt.contains("original rejection SHA: abc123"));
3280        assert!(prompt.contains("fix commit SHA: def456"));
3281        assert!(prompt.contains("petition attempts so far: 1"));
3282        assert!(prompt.contains("Original rejection summary."));
3283        assert!(prompt.contains("thin evidence")); // structured finding title
3284        assert!(prompt.contains("DIFF BODY"));
3285        assert!(prompt.contains("\"verdict\": \"PASS\" | \"REJECT\" | \"FLAG\""));
3286    }
3287
3288    #[test]
3289    fn materialize_petition_prompt_rewrites_request_prompt() {
3290        let mut job = review_job(false);
3291        job.petition = Some(sample_petition(2));
3292        let original_prompt = job.request.prompt.clone();
3293        let updated = super::materialize_petition_prompt(job);
3294
3295        assert_ne!(updated.request.prompt, original_prompt);
3296        assert!(updated.request.prompt.contains("PETITION"));
3297        assert!(updated.request.prompt.contains("fix commit SHA: def456"));
3298        assert!(
3299            updated
3300                .request
3301                .prompt
3302                .contains("petition attempts so far: 2")
3303        );
3304    }
3305
3306    #[test]
3307    fn materialize_petition_prompt_passthrough_when_no_petition() {
3308        let job = review_job(false);
3309        let original_prompt = job.request.prompt.clone();
3310        let updated = super::materialize_petition_prompt(job);
3311        assert_eq!(updated.request.prompt, original_prompt);
3312    }
3313
3314    #[test]
3315    fn subprocess_runner_is_mockable() {
3316        struct MockRunner;
3317
3318        impl ProcessRunner for MockRunner {
3319            fn run(
3320                &self,
3321                invocation: &InvocationPlan,
3322                prompt: &str,
3323            ) -> Result<ProcessOutput, ReviewerError> {
3324                assert_eq!(invocation.program, "codex");
3325                assert_eq!(
3326                    invocation.args_for_prompt(prompt).last().unwrap(),
3327                    "review this"
3328                );
3329                Ok(ProcessOutput {
3330                    status_code: Some(0),
3331                    stdout: pass_json(),
3332                    stderr: String::new(),
3333                })
3334            }
3335        }
3336
3337        let request = ReviewRequest::new(
3338            Agent::Codex,
3339            "gpt-5.4",
3340            ReviewerHarness::Codex,
3341            "gpt-5.5",
3342            false,
3343            "review this",
3344        );
3345        let plan = ReviewPlan::build(request).unwrap();
3346        let output = plan.run_with("review this", &MockRunner).unwrap();
3347
3348        assert!(output.stdout.contains("PASS"));
3349    }
3350
3351    #[test]
3352    fn verdict_parser_extracts_rejection_findings() {
3353        let verdict = ParsedVerdict::parse(&reject_json("missing proof")).unwrap();
3354
3355        assert_eq!(verdict.verdict, Verdict::Reject);
3356        assert_eq!(verdict.structured_findings[0].title, "missing proof");
3357        assert_eq!(verdict.structured_findings[0].confidence, 95);
3358        assert!(verdict.findings[0].contains("missing proof"));
3359        assert_eq!(
3360            verdict.memory_skill_classification.learning_source,
3361            "missing proof"
3362        );
3363    }
3364
3365    fn flag_json(title: &str) -> String {
3366        serde_json::json!({
3367            "verdict": "FLAG",
3368            "summary": "Claim substantiated; flagging thin evidence.",
3369            "findings": [{
3370                "severity": "medium",
3371                "title": title,
3372                "body": "Evidence pointer is thin but the claim holds.",
3373                "file": "src/lib.rs",
3374                "line_start": 1,
3375                "line_end": 1,
3376                "confidence": 60,
3377                "recommendation": "Tighten evidence."
3378            }],
3379            "next_steps": ["Add stronger evidence."],
3380            "memory_skill": {
3381                "kind": "none",
3382                "learning_source": "",
3383                "reasoning": "No reusable procedural memory to propose."
3384            }
3385        })
3386        .to_string()
3387    }
3388
3389    #[test]
3390    fn verdict_parser_extracts_flag_findings() {
3391        let verdict = ParsedVerdict::parse(&flag_json("thin evidence")).unwrap();
3392
3393        assert_eq!(verdict.verdict, Verdict::Flag);
3394        assert_eq!(verdict.structured_findings[0].title, "thin evidence");
3395        assert_eq!(verdict.structured_findings[0].confidence, 60);
3396        assert!(verdict.findings[0].contains("thin evidence"));
3397    }
3398
3399    #[test]
3400    fn verdict_parser_rejects_flag_with_no_findings() {
3401        let output = serde_json::json!({
3402            "verdict": "FLAG",
3403            "summary": "Claim substantiated but flagging.",
3404            "findings": [],
3405            "next_steps": [],
3406            "memory_skill": {
3407                "kind": "none",
3408                "learning_source": "",
3409                "reasoning": "no reusable memory"
3410            }
3411        });
3412
3413        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3414
3415        assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
3416    }
3417
3418    #[test]
3419    fn verdict_parser_accepts_lowercase_flag_via_schema() {
3420        // Reviewer harnesses vary in case; serde rename_all = "UPPERCASE"
3421        // canonicalises, but reviewers may emit lowercase. Confirm the
3422        // existing parser does NOT silently coerce: lowercase should fail
3423        // (the schema is the source of truth for new entries).
3424        let output = serde_json::json!({
3425            "verdict": "flag",
3426            "summary": "Claim substantiated but flagging.",
3427            "findings": [{
3428                "severity": "low",
3429                "title": "x",
3430                "body": "y",
3431                "file": "src/lib.rs",
3432                "line_start": 1,
3433                "line_end": 1,
3434                "confidence": 50,
3435                "recommendation": "z"
3436            }],
3437            "next_steps": [],
3438            "memory_skill": {
3439                "kind": "none",
3440                "learning_source": "",
3441                "reasoning": "none"
3442            }
3443        });
3444
3445        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3446        assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3447    }
3448
3449    #[test]
3450    fn verdict_parser_rejects_missing_memory_skill_classification() {
3451        let output = serde_json::json!({
3452            "verdict": "PASS",
3453            "summary": "The claim is substantiated.",
3454            "findings": [],
3455            "next_steps": []
3456        });
3457
3458        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3459
3460        assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3461    }
3462
3463    #[test]
3464    fn verdict_parser_rejects_incompatible_memory_skill_classification() {
3465        let mut output: serde_json::Value = serde_json::from_str(&pass_json()).unwrap();
3466        output["memory_skill"]["kind"] = serde_json::json!("remediation_skill");
3467
3468        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3469
3470        assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
3471    }
3472
3473    #[test]
3474    fn verdict_parser_accepts_normalized_float_confidence() {
3475        let mut output: serde_json::Value =
3476            serde_json::from_str(&reject_json("missing proof")).unwrap();
3477        output["findings"][0]["confidence"] = serde_json::json!(0.95);
3478
3479        let verdict = ParsedVerdict::parse(&output.to_string()).unwrap();
3480
3481        assert_eq!(verdict.structured_findings[0].confidence, 95);
3482    }
3483
3484    #[test]
3485    fn verdict_parser_rejects_legacy_line_protocol() {
3486        let error =
3487            ParsedVerdict::parse("VERDICT: REJECT\nFINDINGS:\n- missing proof\n").unwrap_err();
3488
3489        assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3490    }
3491
3492    #[test]
3493    fn large_diff_materialization_falls_back_to_file_summary() {
3494        let files = "a.rs\nb.rs\nc.rs\n";
3495        let materialized = super::materialize_diff("branch:main", "tiny diff", files);
3496
3497        assert!(materialized.contains("too large to inline safely"));
3498        assert!(materialized.contains("actual_files=3"));
3499        assert!(materialized.contains("a.rs\nb.rs\nc.rs"));
3500        assert!(materialized.contains("inspect the repository directly"));
3501    }
3502
3503    #[test]
3504    fn review_queue_schedules_commits_without_running_models() {
3505        let temp = tempfile::tempdir().unwrap();
3506        let queue = ReviewQueue::new(temp.path());
3507
3508        queue.enqueue("abc123").unwrap();
3509
3510        let pending = queue.pending().unwrap();
3511        assert_eq!(pending.len(), 1);
3512        assert_eq!(pending[0].commit_sha, "abc123");
3513        assert!(!pending[0].run_id.is_empty());
3514
3515        let run = ReviewRunStore::new(temp.path())
3516            .read(&pending[0].run_id)
3517            .unwrap();
3518        assert_eq!(run.commit_sha, "abc123");
3519        assert_eq!(run.status, ReviewRunStatus::Queued);
3520        assert_eq!(run.entire_checkpoint, None);
3521    }
3522
3523    #[test]
3524    fn review_queue_summary_counts_pending_and_oldest() {
3525        let temp = tempfile::tempdir().unwrap();
3526        let queue = ReviewQueue::new(temp.path());
3527        std::fs::write(
3528            queue.path(),
3529            "{\"commit_sha\":\"abc123\",\"enqueued_at_unix\":30}\n{\"commit_sha\":\"def456\",\"enqueued_at_unix\":10}\n",
3530        )
3531        .unwrap();
3532
3533        let summary = queue.summary().unwrap();
3534
3535        assert_eq!(summary.pending_count, 2);
3536        assert_eq!(summary.oldest_enqueued_at_unix, Some(10));
3537        assert_eq!(summary.oldest_age_secs_at(42), Some(32));
3538    }
3539
3540    #[test]
3541    fn review_run_serializes_optional_entire_checkpoint() {
3542        let run = ReviewRun::queued_with_provenance(
3543            "run-id",
3544            "abcdef123456",
3545            "commit",
3546            Some(crate::provenance::EntireCheckpointRef {
3547                ref_name: "entire/session-abcdef".to_owned(),
3548                object_sha: "123456".to_owned(),
3549            }),
3550        );
3551
3552        let json = serde_json::to_string(&run).unwrap();
3553
3554        assert!(json.contains("entire_checkpoint"));
3555        assert!(json.contains("entire/session-abcdef"));
3556    }
3557
3558    #[test]
3559    fn review_run_omits_absent_optional_entire_checkpoint() {
3560        let run = ReviewRun::queued("run-id", "abcdef123456", "commit");
3561
3562        let json = serde_json::to_string(&run).unwrap();
3563
3564        assert!(!json.contains("entire_checkpoint"));
3565    }
3566
3567    #[test]
3568    fn review_run_status_counts_read_only_status_fields() {
3569        let temp = tempfile::tempdir().unwrap();
3570        let store = ReviewRunStore::new(temp.path());
3571        let queued = store.create_queued("abc123", "commit").unwrap();
3572        let mut failed = ReviewRun::queued("failed-run", "def456", "commit");
3573        failed.mark_failed("boom");
3574        store.write(&failed).unwrap();
3575        fs::write(store.path("malformed-run"), "{not-json\n").unwrap();
3576
3577        let counts = store.status_counts().unwrap();
3578
3579        assert_eq!(counts.queued, 1);
3580        assert_eq!(counts.failed, 1);
3581        assert_eq!(counts.running, 0);
3582        assert_eq!(counts.skipped_records, 1);
3583        assert_eq!(queued.status, ReviewRunStatus::Queued);
3584    }
3585
3586    #[test]
3587    fn review_cancel_marks_queued_run_and_removes_queue_item() {
3588        let temp = tempfile::tempdir().unwrap();
3589        let queue = ReviewQueue::new(temp.path());
3590        let queued = queue.enqueue("abc123").unwrap();
3591
3592        run_review_run_command(
3593            crate::cli::ReviewCommand::Cancel {
3594                run_id: queued.run_id.clone(),
3595                force: false,
3596            },
3597            temp.path(),
3598        )
3599        .unwrap();
3600
3601        assert!(queue.pending().unwrap().is_empty());
3602        let run = ReviewRunStore::new(temp.path())
3603            .read(&queued.run_id)
3604            .unwrap();
3605        assert_eq!(run.status, ReviewRunStatus::Cancelled);
3606    }
3607
3608    /// A pid guaranteed to be dead: spawn a trivial process, reap it, return its pid.
3609    fn reaped_pid() -> u32 {
3610        let mut child = Command::new("true").spawn().expect("spawn `true`");
3611        let pid = child.id();
3612        child.wait().expect("reap `true`");
3613        pid
3614    }
3615
3616    /// Persist a run pinned to `Running` with a specific worker pid, bypassing the
3617    /// normal lifecycle so liveness handling can be exercised deterministically.
3618    fn write_running_run(store: &ReviewRunStore, worker_pid: Option<u32>) -> ReviewRun {
3619        let mut run = store.create_queued("abc123", "commit").unwrap();
3620        run.status = ReviewRunStatus::Running;
3621        run.phase = "reviewing".to_owned();
3622        run.worker_pid = worker_pid;
3623        store.write(&run).unwrap();
3624        run
3625    }
3626
3627    #[test]
3628    fn pid_liveness_probe_tracks_real_processes() {
3629        assert!(pid_is_alive(std::process::id()));
3630        assert!(!pid_is_alive(reaped_pid()));
3631    }
3632
3633    #[test]
3634    fn reconcile_liveness_only_reaps_dead_running_runs() {
3635        let mut queued = ReviewRun::queued("id", "abc123", "commit");
3636        // A queued run is never touched, even if the predicate reports dead.
3637        assert!(!queued.reconcile_liveness(|_| false));
3638        assert_eq!(queued.status, ReviewRunStatus::Queued);
3639
3640        queued.mark_running("reviewing");
3641        // Running + alive stays running.
3642        assert!(!queued.reconcile_liveness(|_| true));
3643        assert_eq!(queued.status, ReviewRunStatus::Running);
3644        // Running + dead flips to failed with a stale-worker reason.
3645        assert!(queued.reconcile_liveness(|_| false));
3646        assert_eq!(queued.status, ReviewRunStatus::Failed);
3647        assert!(queued.error.as_deref().unwrap().contains("stale run"));
3648        assert!(queued.worker_pid.is_none());
3649
3650        // A legacy running run with no recorded pid is left alone.
3651        let mut legacy = ReviewRun::queued("id2", "def456", "commit");
3652        legacy.status = ReviewRunStatus::Running;
3653        legacy.worker_pid = None;
3654        assert!(!legacy.reconcile_liveness(|_| false));
3655        assert_eq!(legacy.status, ReviewRunStatus::Running);
3656    }
3657
3658    #[test]
3659    fn review_status_reaps_running_run_with_dead_worker_and_persists() {
3660        let temp = tempfile::tempdir().unwrap();
3661        let store = ReviewRunStore::new(temp.path());
3662        let run = write_running_run(&store, Some(reaped_pid()));
3663
3664        let reconciled = store.read_reconciled(&run.id).unwrap();
3665        assert_eq!(reconciled.status, ReviewRunStatus::Failed);
3666        assert!(reconciled.error.as_deref().unwrap().contains("stale run"));
3667
3668        // The correction is persisted, not just displayed.
3669        assert_eq!(store.read(&run.id).unwrap().status, ReviewRunStatus::Failed);
3670        // list_reconciled agrees.
3671        let listed = store.list_reconciled().unwrap();
3672        assert_eq!(listed.len(), 1);
3673        assert_eq!(listed[0].status, ReviewRunStatus::Failed);
3674    }
3675
3676    #[test]
3677    fn review_status_leaves_running_run_with_live_worker() {
3678        let temp = tempfile::tempdir().unwrap();
3679        let store = ReviewRunStore::new(temp.path());
3680        let run = write_running_run(&store, Some(std::process::id()));
3681
3682        let reconciled = store.read_reconciled(&run.id).unwrap();
3683        assert_eq!(reconciled.status, ReviewRunStatus::Running);
3684    }
3685
3686    #[test]
3687    fn cancel_reaps_running_run_with_dead_worker_without_force() {
3688        let temp = tempfile::tempdir().unwrap();
3689        let store = ReviewRunStore::new(temp.path());
3690        let run = write_running_run(&store, Some(reaped_pid()));
3691
3692        let cancelled = store.cancel(&run.id, false).unwrap();
3693        assert_eq!(cancelled.status, ReviewRunStatus::Failed);
3694        assert!(cancelled.error.as_deref().unwrap().contains("stale run"));
3695    }
3696
3697    #[test]
3698    fn cancel_refuses_live_running_run_without_force() {
3699        let temp = tempfile::tempdir().unwrap();
3700        let store = ReviewRunStore::new(temp.path());
3701        let run = write_running_run(&store, Some(std::process::id()));
3702
3703        let error = store.cancel(&run.id, false).unwrap_err();
3704        assert!(matches!(error, ReviewerError::ReviewRunStillAlive { .. }));
3705        // The run is untouched.
3706        assert_eq!(
3707            store.read(&run.id).unwrap().status,
3708            ReviewRunStatus::Running
3709        );
3710    }
3711
3712    #[test]
3713    fn cancel_force_kills_live_worker_and_cancels() {
3714        let temp = tempfile::tempdir().unwrap();
3715        let store = ReviewRunStore::new(temp.path());
3716        let mut child = Command::new("sleep")
3717            .arg("30")
3718            .spawn()
3719            .expect("spawn sleep");
3720        let pid = child.id();
3721        let run = write_running_run(&store, Some(pid));
3722
3723        let cancelled = store.cancel(&run.id, true).unwrap();
3724        assert_eq!(cancelled.status, ReviewRunStatus::Cancelled);
3725
3726        // Reap the killed child, then confirm it is truly gone.
3727        let _ = child.wait();
3728        assert!(!pid_is_alive(pid));
3729    }
3730
3731    #[test]
3732    fn cancel_legacy_running_run_requires_force_then_reaps() {
3733        let temp = tempfile::tempdir().unwrap();
3734        let store = ReviewRunStore::new(temp.path());
3735        let run = write_running_run(&store, None);
3736
3737        let error = store.cancel(&run.id, false).unwrap_err();
3738        assert!(matches!(
3739            error,
3740            ReviewerError::ReviewRunLivenessUnknown { .. }
3741        ));
3742
3743        let reaped = store.cancel(&run.id, true).unwrap();
3744        assert_eq!(reaped.status, ReviewRunStatus::Failed);
3745    }
3746
3747    #[test]
3748    fn cancel_refuses_already_terminal_run() {
3749        let temp = tempfile::tempdir().unwrap();
3750        let store = ReviewRunStore::new(temp.path());
3751        let run = store.create_queued("abc123", "commit").unwrap();
3752        store.mark_completed(&run.id, 0).unwrap();
3753
3754        let error = store.cancel(&run.id, true).unwrap_err();
3755        assert!(matches!(
3756            error,
3757            ReviewerError::CannotCancelReview {
3758                status: ReviewRunStatus::Completed,
3759                ..
3760            }
3761        ));
3762    }
3763
3764    #[test]
3765    fn execute_review_records_reject_verdict() {
3766        let temp = tempfile::tempdir().unwrap();
3767        let store = LedgerStore::new(temp.path());
3768        let job = review_job(false);
3769        let runner = SequenceRunner::new([reject_json("unsupported")]);
3770
3771        let execution = execute_review_job(job, &runner, &store).unwrap();
3772
3773        assert_eq!(execution.entries.len(), 1);
3774        assert_eq!(execution.entries[0].verdict, Verdict::Reject);
3775        assert_eq!(
3776            execution.entries[0].structured_findings[0].title,
3777            "unsupported"
3778        );
3779        assert!(
3780            execution.entries[0]
3781                .raw_reviewer_output
3782                .contains("\"REJECT\"")
3783        );
3784        assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3785    }
3786
3787    #[test]
3788    fn strict_two_pass_records_both_clean_passes() {
3789        let temp = tempfile::tempdir().unwrap();
3790        let store = LedgerStore::new(temp.path());
3791        let job = review_job(true);
3792        let runner = SequenceRunner::new([pass_json(), pass_json()]);
3793
3794        let execution = execute_review_job(job, &runner, &store).unwrap();
3795
3796        assert_eq!(execution.entries.len(), 2);
3797        assert_eq!(store.read_history().unwrap().len(), 2);
3798        assert_eq!(execution.entries[0].reviewer.model, "gpt-5.5");
3799        assert_eq!(execution.entries[1].reviewer.model, "claude-opus-4-8");
3800    }
3801
3802    #[test]
3803    fn strict_arbiter_model_must_be_third_model() {
3804        let temp = tempfile::tempdir().unwrap();
3805        let store = LedgerStore::new(temp.path());
3806        let mut job = review_job(true);
3807        job.strict.as_mut().unwrap().arbiter_model = "gpt-5.5".to_owned();
3808        let runner = SequenceRunner::new([pass_json()]);
3809
3810        let error = execute_review_job(job, &runner, &store).unwrap_err();
3811
3812        assert!(matches!(
3813            error,
3814            ReviewerError::StrictArbiterModelNotDistinct
3815        ));
3816    }
3817
3818    #[test]
3819    fn strict_goal_policy_stops_at_configured_lie_or_fuckup_count() {
3820        let policy = StrictGoalPolicy {
3821            stop_after_lies: 2,
3822            stop_after_fuckups: 3,
3823        };
3824
3825        assert_eq!(
3826            policy.decide(StrictGoalCounters {
3827                lies_exposed: 1,
3828                fuckups_registered: 2
3829            }),
3830            StrictGoalDecision::Continue
3831        );
3832        assert_eq!(
3833            policy.decide(StrictGoalCounters {
3834                lies_exposed: 2,
3835                fuckups_registered: 0
3836            }),
3837            StrictGoalDecision::Stop {
3838                reason: StrictGoalStopReason::LiesExposed
3839            }
3840        );
3841        assert_eq!(
3842            policy.decide(StrictGoalCounters {
3843                lies_exposed: 0,
3844                fuckups_registered: 3
3845            }),
3846            StrictGoalDecision::Stop {
3847                reason: StrictGoalStopReason::FuckupsRegistered
3848            }
3849        );
3850    }
3851
3852    #[test]
3853    fn drain_once_reviews_each_commit_once_and_clears_queue() {
3854        let temp = tempfile::tempdir().unwrap();
3855        let store = LedgerStore::new(temp.path());
3856        let queue = ReviewQueue::new(temp.path());
3857        queue.enqueue("abc123").unwrap();
3858        queue.enqueue("abc123").unwrap(); // duplicate SHA reviewed only once
3859        queue.enqueue("def456").unwrap();
3860
3861        let loader = StaticLoader::new();
3862        let runner = SequenceRunner::new([reject_json("unsupported"), pass_json()]);
3863        let selection = selection();
3864
3865        let report = drain_once(
3866            &queue,
3867            &loader,
3868            &selection,
3869            "",
3870            &runner,
3871            &store,
3872            &TruthMirrorConfig::default(),
3873        )
3874        .unwrap();
3875
3876        assert_eq!(report.reviewed, ["abc123", "def456"]);
3877        assert_eq!(report.ledger_entries, 2);
3878        assert!(queue.pending().unwrap().is_empty());
3879        assert_eq!(store.read_history().unwrap().len(), 2);
3880        assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3881
3882        let runs = ReviewRunStore::new(temp.path()).list().unwrap();
3883        assert_eq!(runs.len(), 3);
3884        assert_eq!(
3885            runs.iter()
3886                .filter(|run| run.status == ReviewRunStatus::Completed)
3887                .count(),
3888            2
3889        );
3890        assert_eq!(
3891            runs.iter()
3892                .filter(|run| run.status == ReviewRunStatus::Cancelled)
3893                .count(),
3894            1
3895        );
3896    }
3897
3898    #[test]
3899    fn drain_once_skips_a_poisoned_claim_and_reviews_surrounding_items() {
3900        let temp = tempfile::tempdir().unwrap();
3901        let store = LedgerStore::new(temp.path());
3902        let queue = ReviewQueue::new(temp.path());
3903        queue.enqueue("good-one").unwrap();
3904        queue.enqueue("poisoned").unwrap();
3905        queue.enqueue("good-two").unwrap();
3906
3907        let loader = PoisonedLoader;
3908        let runner = ConstRunner::new(pass_json());
3909        let report = drain_once(
3910            &queue,
3911            &loader,
3912            &selection(),
3913            "",
3914            &runner,
3915            &store,
3916            &TruthMirrorConfig::default(),
3917        )
3918        .unwrap();
3919
3920        assert_eq!(report.reviewed, ["good-one", "good-two"]);
3921        assert_eq!(report.failed, ["poisoned"]);
3922        assert!(queue.pending().unwrap().is_empty());
3923        assert_eq!(store.read_history().unwrap().len(), 2);
3924        let poisoned_run = ReviewRunStore::new(temp.path())
3925            .list()
3926            .unwrap()
3927            .into_iter()
3928            .find(|run| run.commit_sha == "poisoned")
3929            .unwrap();
3930        assert_eq!(poisoned_run.status, ReviewRunStatus::Failed);
3931        assert!(
3932            poisoned_run
3933                .error
3934                .as_deref()
3935                .is_some_and(|error| error.contains("missing evidence pointer"))
3936        );
3937    }
3938
3939    #[test]
3940    fn drain_once_completes_when_memory_skill_extraction_fails() {
3941        let temp = tempfile::tempdir().unwrap();
3942        let store = LedgerStore::new(temp.path());
3943        let queue = ReviewQueue::new(temp.path());
3944        let sha = "a".repeat(40);
3945        queue.enqueue(&sha).unwrap();
3946        let loader = StaticLoader::new();
3947        let runner = ConstRunner::new(pass_json());
3948        let mut config = TruthMirrorConfig::default();
3949        config.skills.enabled = true;
3950        config.memory_skill.enabled = true;
3951        config.memory_skill.signals.min_occurrences = 1;
3952        config.memory_skill.scan.blocked_patterns = vec!["Capture a verified procedure".to_owned()];
3953
3954        let report =
3955            drain_once(&queue, &loader, &selection(), "", &runner, &store, &config).unwrap();
3956
3957        assert_eq!(report.reviewed, [sha]);
3958        assert_eq!(report.ledger_entries, 1);
3959        assert!(queue.pending().unwrap().is_empty());
3960        assert_eq!(store.read_history().unwrap().len(), 1);
3961        let candidate_dir =
3962            crate::memory_skill::MemorySkillStore::new(temp.path(), &config.memory_skill)
3963                .unwrap()
3964                .candidate_dir()
3965                .to_path_buf();
3966        assert!(!candidate_dir.exists());
3967    }
3968
3969    #[test]
3970    fn drain_once_is_a_noop_on_empty_queue() {
3971        let temp = tempfile::tempdir().unwrap();
3972        let store = LedgerStore::new(temp.path());
3973        let queue = ReviewQueue::new(temp.path());
3974        let loader = StaticLoader::new();
3975        let runner = ConstRunner::new(pass_json());
3976
3977        let report = drain_once(
3978            &queue,
3979            &loader,
3980            &selection(),
3981            "",
3982            &runner,
3983            &store,
3984            &TruthMirrorConfig::default(),
3985        )
3986        .unwrap();
3987
3988        assert!(report.reviewed.is_empty());
3989        assert_eq!(report.ledger_entries, 0);
3990        assert_eq!(store.read_history().unwrap().len(), 0);
3991    }
3992
3993    #[test]
3994    fn until_empty_keeps_watching_while_queue_has_items() {
3995        // A non-empty queue is reported as `None` elapsed → keep watching.
3996        assert_eq!(
3997            until_empty_decision(None, 60),
3998            UntilEmptyDecision::KeepWatching
3999        );
4000    }
4001
4002    #[test]
4003    fn until_empty_keeps_watching_inside_grace_window() {
4004        // Empty for less than the grace window → keep watching for late arrivals.
4005        assert_eq!(
4006            until_empty_decision(Some(0), 60),
4007            UntilEmptyDecision::KeepWatching
4008        );
4009        assert_eq!(
4010            until_empty_decision(Some(59), 60),
4011            UntilEmptyDecision::KeepWatching
4012        );
4013    }
4014
4015    #[test]
4016    fn until_empty_exits_once_grace_window_elapses() {
4017        // Empty through a full grace window (>= grace) → exit.
4018        assert_eq!(until_empty_decision(Some(60), 60), UntilEmptyDecision::Exit);
4019        assert_eq!(
4020            until_empty_decision(Some(120), 60),
4021            UntilEmptyDecision::Exit
4022        );
4023    }
4024
4025    #[test]
4026    fn until_empty_with_zero_grace_exits_immediately_when_empty() {
4027        assert_eq!(until_empty_decision(Some(0), 0), UntilEmptyDecision::Exit);
4028        // Still keeps watching while items remain, even with zero grace.
4029        assert_eq!(
4030            until_empty_decision(None, 0),
4031            UntilEmptyDecision::KeepWatching
4032        );
4033    }
4034
4035    #[test]
4036    fn strict_goal_loop_stops_at_configured_lie_count() {
4037        let temp = tempfile::tempdir().unwrap();
4038        let store = LedgerStore::new(temp.path());
4039        let policy = StrictGoalPolicy {
4040            stop_after_lies: 1,
4041            stop_after_fuckups: 0,
4042        };
4043        let runner = SequenceRunner::new([reject_json("lie")]);
4044
4045        let outcome = run_strict_goal_loop(
4046            "abc123",
4047            &claim(),
4048            "diff",
4049            "",
4050            &selection(),
4051            policy,
4052            5,
4053            &runner,
4054            &store,
4055        )
4056        .unwrap();
4057
4058        assert_eq!(outcome.passes, 1);
4059        assert_eq!(outcome.counters.lies_exposed, 1);
4060        assert_eq!(outcome.stop_reason, Some(StrictGoalStopReason::LiesExposed));
4061        assert_eq!(store.read_history().unwrap().len(), 1);
4062    }
4063
4064    #[test]
4065    fn strict_goal_loop_terminates_at_max_passes_for_honest_agent() {
4066        let temp = tempfile::tempdir().unwrap();
4067        let store = LedgerStore::new(temp.path());
4068        let policy = StrictGoalPolicy {
4069            stop_after_lies: 2,
4070            stop_after_fuckups: 5,
4071        };
4072        let runner = ConstRunner::new(pass_json());
4073
4074        let outcome = run_strict_goal_loop(
4075            "abc123",
4076            &claim(),
4077            "diff",
4078            "",
4079            &selection(),
4080            policy,
4081            3,
4082            &runner,
4083            &store,
4084        )
4085        .unwrap();
4086
4087        assert_eq!(outcome.passes, 3);
4088        assert_eq!(outcome.counters.lies_exposed, 0);
4089        assert_eq!(outcome.stop_reason, None);
4090        assert_eq!(store.read_history().unwrap().len(), 3);
4091    }
4092
4093    #[test]
4094    fn strict_goal_loop_stops_when_fuckups_accumulate() {
4095        let temp = tempfile::tempdir().unwrap();
4096        let store = LedgerStore::new(temp.path());
4097        let policy = StrictGoalPolicy {
4098            stop_after_lies: 0,
4099            stop_after_fuckups: 2,
4100        };
4101        // Each structured finding registers one fuckup; two passes hit N=2.
4102        let runner = ConstRunner::new(reject_json("nit"));
4103
4104        let outcome = run_strict_goal_loop(
4105            "abc123",
4106            &claim(),
4107            "diff",
4108            "",
4109            &selection(),
4110            policy,
4111            10,
4112            &runner,
4113            &store,
4114        )
4115        .unwrap();
4116
4117        assert_eq!(outcome.passes, 2);
4118        assert_eq!(outcome.counters.lies_exposed, 2);
4119        assert_eq!(outcome.counters.fuckups_registered, 2);
4120        assert_eq!(
4121            outcome.stop_reason,
4122            Some(StrictGoalStopReason::FuckupsRegistered)
4123        );
4124    }
4125
4126    proptest! {
4127        #[test]
4128        fn strict_goal_loop_never_exceeds_max_passes(max in 1u32..6) {
4129            let temp = tempfile::tempdir().unwrap();
4130            let store = LedgerStore::new(temp.path());
4131            // Both thresholds disabled: only the ceiling can stop the loop.
4132            let policy = StrictGoalPolicy { stop_after_lies: 0, stop_after_fuckups: 0 };
4133            let runner = ConstRunner::new(pass_json());
4134
4135            let outcome = run_strict_goal_loop(
4136                "abc123", &claim(), "diff", "", &selection(), policy, max, &runner, &store,
4137            )
4138            .unwrap();
4139
4140            prop_assert!(outcome.passes <= max);
4141            prop_assert_eq!(outcome.passes, max);
4142            prop_assert!(outcome.stop_reason.is_none());
4143        }
4144    }
4145
4146    proptest! {
4147        #[test]
4148        fn model_opposition_is_enforced_for_arbitrary_models(
4149            watched in "[A-Za-z0-9._/-]{1,32}",
4150            reviewer in "[A-Za-z0-9._/-]{1,32}",
4151        ) {
4152            let request = ReviewRequest::new(
4153                Agent::Codex,
4154                watched.clone(),
4155                ReviewerHarness::Codex,
4156                reviewer.clone(),
4157                false,
4158                "review this",
4159            );
4160            let result = ReviewPlan::build(request);
4161
4162            // Use the same normalisation that ReviewPlan::build uses so that
4163            // provider-prefixed models (e.g. "openai-codex/gpt-5.5" vs "gpt-5.5")
4164            // are treated as the same model after the B2 fix.
4165            if normalized_model(watched.trim()) == normalized_model(reviewer.trim()) {
4166                let blocked = matches!(result, Err(ReviewerError::SameModelWithoutWaiver { .. }));
4167                prop_assert!(blocked);
4168            } else {
4169                prop_assert!(result.is_ok());
4170            }
4171        }
4172    }
4173
4174    fn claim() -> Claim {
4175        Claim::new(
4176            "add review",
4177            "cargo test",
4178            vec![EvidenceRef::parse("tests:cargo-test").unwrap()],
4179        )
4180        .unwrap()
4181    }
4182
4183    fn selection() -> ReviewSelection {
4184        ReviewSelection {
4185            watched_agent: Agent::Codex,
4186            watched_model: "gpt-5.4".to_owned(),
4187            reviewer_harness: ReviewerHarness::Codex,
4188            reviewer_model: "gpt-5.5".to_owned(),
4189            reviewer_effort: Effort::Xhigh,
4190            allow_same_model: false,
4191            strict: None,
4192        }
4193    }
4194
4195    struct StaticLoader {
4196        claim: Claim,
4197        diff: String,
4198    }
4199
4200    impl StaticLoader {
4201        fn new() -> Self {
4202            Self {
4203                claim: claim(),
4204                diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
4205            }
4206        }
4207    }
4208
4209    impl MaterialLoader for StaticLoader {
4210        fn load(&self, _sha: &str) -> Result<(Claim, String), ReviewerError> {
4211            Ok((self.claim.clone(), self.diff.clone()))
4212        }
4213    }
4214
4215    struct PoisonedLoader;
4216
4217    impl MaterialLoader for PoisonedLoader {
4218        fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError> {
4219            if sha == "poisoned" {
4220                return Err(crate::claim::ClaimError::MissingEvidence.into());
4221            }
4222            StaticLoader::new().load(sha)
4223        }
4224    }
4225
4226    struct ConstRunner {
4227        output: String,
4228    }
4229
4230    impl ConstRunner {
4231        fn new(output: impl Into<String>) -> Self {
4232            Self {
4233                output: output.into(),
4234            }
4235        }
4236    }
4237
4238    impl ProcessRunner for ConstRunner {
4239        fn run(
4240            &self,
4241            _invocation: &InvocationPlan,
4242            _prompt: &str,
4243        ) -> Result<ProcessOutput, ReviewerError> {
4244            Ok(ProcessOutput {
4245                status_code: Some(0),
4246                stdout: self.output.clone(),
4247                stderr: String::new(),
4248            })
4249        }
4250    }
4251
4252    fn review_job(strict: bool) -> ReviewJob {
4253        let claim = claim();
4254        ReviewJob {
4255            commit_sha: "abc123".to_owned(),
4256            diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
4257            context: String::new(),
4258            request: ReviewRequest::new(
4259                Agent::Codex,
4260                "gpt-5.4",
4261                ReviewerHarness::Codex,
4262                "gpt-5.5",
4263                false,
4264                "review this",
4265            ),
4266            claim,
4267            strict: strict.then_some(StrictReviewConfig {
4268                arbiter_harness: ReviewerHarness::Claude,
4269                arbiter_model: "claude-opus-4-8".to_owned(),
4270                arbiter_effort: Effort::Xhigh,
4271            }),
4272            petition: None,
4273        }
4274    }
4275
4276    struct SequenceRunner {
4277        outputs: RefCell<VecDeque<String>>,
4278    }
4279
4280    impl SequenceRunner {
4281        fn new<I, S>(outputs: I) -> Self
4282        where
4283            I: IntoIterator<Item = S>,
4284            S: Into<String>,
4285        {
4286            Self {
4287                outputs: RefCell::new(outputs.into_iter().map(Into::into).collect()),
4288            }
4289        }
4290    }
4291
4292    impl ProcessRunner for SequenceRunner {
4293        fn run(
4294            &self,
4295            _invocation: &InvocationPlan,
4296            _prompt: &str,
4297        ) -> Result<ProcessOutput, ReviewerError> {
4298            let stdout = self.outputs.borrow_mut().pop_front().unwrap();
4299            Ok(ProcessOutput {
4300                status_code: Some(0),
4301                stdout,
4302                stderr: String::new(),
4303            })
4304        }
4305    }
4306
4307    #[test]
4308    fn extract_verdict_json_handles_raw_fenced_and_prose_wrapped_output() {
4309        // Raw JSON (the documented contract) passes through untouched.
4310        let raw = r#"{"verdict":"PASS"}"#;
4311        assert_eq!(super::extract_verdict_json(raw), raw);
4312
4313        // Prose around a ```json fence: the fenced body is extracted.
4314        let fenced = "Here is my verdict:\n```json\n{\"verdict\":\"PASS\"}\n```\nthanks";
4315        assert_eq!(
4316            super::extract_verdict_json(fenced),
4317            "{\"verdict\":\"PASS\"}"
4318        );
4319
4320        // Bare ``` fence without an info string.
4321        let bare = "verdict below\n```\n{\"a\":1}\n```";
4322        assert_eq!(super::extract_verdict_json(bare), "{\"a\":1}");
4323
4324        // A non-JSON fence first, the JSON fence second: skips to the JSON one.
4325        let two = "```\nnot json\n```\nthen\n```json\n{\"b\":2}\n```";
4326        assert_eq!(super::extract_verdict_json(two), "{\"b\":2}");
4327
4328        // No fence at all: the outermost brace span.
4329        let prose = "The verdict is {\"a\": {\"b\":2}} as required.";
4330        assert_eq!(super::extract_verdict_json(prose), "{\"a\": {\"b\":2}}");
4331
4332        // Nothing JSON-shaped: falls back to the trimmed output so serde's
4333        // error carries the real text.
4334        assert_eq!(super::extract_verdict_json("  nope  "), "nope");
4335    }
4336
4337    #[test]
4338    fn parsed_verdict_accepts_markdown_fenced_reviewer_output() {
4339        // The dead-watcher regression: reviewer models wrap the verdict in
4340        // prose + a fenced block; 0.8.0 demanded JSON at byte 0 and the first
4341        // real review errored out, so the queue never drained.
4342        let fenced = format!(
4343            "The fix looks good. Verdict follows.\n\n```json\n{}\n```\n",
4344            pass_json()
4345        );
4346        let parsed = ParsedVerdict::parse(&fenced).unwrap();
4347        assert_eq!(parsed.verdict, Verdict::Pass);
4348        // `raw` preserves the reviewer's original output verbatim.
4349        assert_eq!(parsed.raw, fenced);
4350    }
4351
4352    #[test]
4353    fn queued_review_lines_without_petition_field_still_parse() {
4354        let legacy = r#"{"run_id":"r1","commit_sha":"abc","enqueued_at_unix":1}"#;
4355        let item: super::QueuedReview = serde_json::from_str(legacy).unwrap();
4356        assert_eq!(item.petition_for, None);
4357    }
4358
4359    #[test]
4360    fn enqueue_petition_round_trips_petition_for() {
4361        let temp = tempfile::tempdir().unwrap();
4362        let queue = ReviewQueue::new(temp.path());
4363        queue.enqueue("normal1").unwrap();
4364        queue.enqueue_petition("fix456", "orig123").unwrap();
4365
4366        let pending = queue.pending().unwrap();
4367        assert_eq!(pending.len(), 2);
4368        assert_eq!(pending[0].petition_for, None);
4369        assert_eq!(pending[1].commit_sha, "fix456");
4370        assert_eq!(pending[1].petition_for.as_deref(), Some("orig123"));
4371    }
4372
4373    #[test]
4374    fn drain_once_runs_petition_jobs_and_transitions_the_original_rejection() {
4375        use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4376
4377        let temp = tempfile::tempdir().unwrap();
4378        let store = LedgerStore::new(temp.path());
4379        let queue = ReviewQueue::new(temp.path());
4380
4381        // The original rejection, plus the CLI's attempt bookkeeping
4382        // (`resolve --fixed-by` counts the attempt when it enqueues).
4383        let rejected = LedgerEntry::new(
4384            "orig123",
4385            Verdict::Reject,
4386            "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4387            vec!["tests:cargo-test".to_owned()],
4388            ReviewerConfig::new("claude", "claude-opus-4-1", false),
4389            vec!["evidence too thin".to_owned()],
4390        );
4391        store.append_entry(&rejected).unwrap();
4392        store
4393            .append_petition_transition(
4394                "orig123",
4395                Disposition::Open,
4396                ResolutionKind::Resolved,
4397                "petition review enqueued: fix=fix456, attempts=1/2",
4398                1,
4399            )
4400            .unwrap();
4401        queue.enqueue_petition("fix456", "orig123").unwrap();
4402
4403        let loader = StaticLoader::new();
4404        let runner = ConstRunner::new(pass_json());
4405        let report = drain_once(
4406            &queue,
4407            &loader,
4408            &selection(),
4409            "",
4410            &runner,
4411            &store,
4412            &TruthMirrorConfig::default(),
4413        )
4414        .unwrap();
4415
4416        assert_eq!(report.reviewed, ["fix456"]);
4417        // The petition review entry lands under the FIX sha, tagged with the
4418        // original, carrying the CLI's counter unchanged (no double increment).
4419        let review = store.show("fix456").unwrap();
4420        assert_eq!(review.petition_for.as_deref(), Some("orig123"));
4421        assert_eq!(review.petition_attempts, 1);
4422        // apply_petition_transition resolved the original rejection.
4423        let original = store.show("orig123").unwrap();
4424        assert_eq!(original.disposition, Disposition::Resolved);
4425        assert!(queue.pending().unwrap().is_empty());
4426        assert!(store.blocking_rejections().unwrap().is_empty());
4427    }
4428
4429    #[test]
4430    fn extract_verdict_json_takes_leading_json_before_trailing_prose() {
4431        // Round-2 review finding: leading JSON + trailing prose used to be
4432        // returned whole (starts_with '{'), and serde died on the trailer.
4433        let leading = "{\"verdict\":\"PASS\"} \n\nHope this helps!";
4434        assert_eq!(
4435            super::extract_verdict_json(leading),
4436            "{\"verdict\":\"PASS\"}"
4437        );
4438
4439        // Prose on both sides without fences: first complete JSON value wins
4440        // even when the trailing prose contains a stray closing brace.
4441        let both = "verdict: {\"a\":1} trailing prose with a stray } brace";
4442        assert_eq!(super::extract_verdict_json(both), "{\"a\":1}");
4443    }
4444
4445    #[test]
4446    fn drain_once_reviews_normal_and_petition_items_for_the_same_fix_sha() {
4447        use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4448
4449        let temp = tempfile::tempdir().unwrap();
4450        let store = LedgerStore::new(temp.path());
4451        let queue = ReviewQueue::new(temp.path());
4452
4453        let rejected = LedgerEntry::new(
4454            "orig123",
4455            Verdict::Reject,
4456            "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4457            vec!["tests:cargo-test".to_owned()],
4458            ReviewerConfig::new("claude", "claude-opus-4-1", false),
4459            vec!["evidence too thin".to_owned()],
4460        );
4461        store.append_entry(&rejected).unwrap();
4462        store
4463            .append_petition_transition(
4464                "orig123",
4465                Disposition::Open,
4466                ResolutionKind::Resolved,
4467                "petition review enqueued: fix=fix456, attempts=1/2",
4468                1,
4469            )
4470            .unwrap();
4471
4472        // The SAME fix commit is queued twice with different identities: its
4473        // own post-commit review AND a petition review. Dedup by bare SHA
4474        // used to cancel one of them silently.
4475        queue.enqueue("fix456").unwrap();
4476        queue.enqueue_petition("fix456", "orig123").unwrap();
4477
4478        let loader = StaticLoader::new();
4479        let runner = ConstRunner::new(pass_json());
4480        let report = drain_once(
4481            &queue,
4482            &loader,
4483            &selection(),
4484            "",
4485            &runner,
4486            &store,
4487            &TruthMirrorConfig::default(),
4488        )
4489        .unwrap();
4490
4491        assert_eq!(report.reviewed, ["fix456", "fix456"]);
4492        assert!(queue.pending().unwrap().is_empty());
4493        // Both entries exist in history: the fix commit's own PASS and the
4494        // petition review tagged with the original rejection.
4495        let history = store.read_history().unwrap();
4496        let fix_entries: Vec<_> = history
4497            .iter()
4498            .filter(|entry| entry.commit_sha == "fix456")
4499            .collect();
4500        assert_eq!(fix_entries.len(), 2);
4501        assert!(fix_entries.iter().any(|entry| entry.petition_for.is_none()));
4502        assert!(
4503            fix_entries
4504                .iter()
4505                .any(|entry| entry.petition_for.as_deref() == Some("orig123"))
4506        );
4507        // And the original rejection resolved via the petition leg.
4508        assert_eq!(
4509            store.show("orig123").unwrap().disposition,
4510            crate::ledger::Disposition::Resolved
4511        );
4512    }
4513
4514    #[test]
4515    fn flag_petition_verdict_resolves_the_original_rejection() {
4516        use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4517
4518        let temp = tempfile::tempdir().unwrap();
4519        let store = LedgerStore::new(temp.path());
4520        let queue = ReviewQueue::new(temp.path());
4521        let rejected = LedgerEntry::new(
4522            "orig123",
4523            Verdict::Reject,
4524            "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4525            vec!["tests:cargo-test".to_owned()],
4526            ReviewerConfig::new("claude", "claude-opus-4-1", false),
4527            vec!["evidence too thin".to_owned()],
4528        );
4529        store.append_entry(&rejected).unwrap();
4530        store
4531            .append_petition_transition(
4532                "orig123",
4533                Disposition::Open,
4534                ResolutionKind::Resolved,
4535                "petition review enqueued: fix=fix456, attempts=1/2",
4536                1,
4537            )
4538            .unwrap();
4539        queue.enqueue_petition("fix456", "orig123").unwrap();
4540
4541        // FLAG = the fix materially addresses the findings + non-blocking
4542        // debt (per the petition prompt) — it must ACCEPT, with the debt
4543        // recorded on the petition review entry itself.
4544        let loader = StaticLoader::new();
4545        let runner = ConstRunner::new(flag_json("evidence could be tighter"));
4546        drain_once(
4547            &queue,
4548            &loader,
4549            &selection(),
4550            "",
4551            &runner,
4552            &store,
4553            &TruthMirrorConfig::default(),
4554        )
4555        .unwrap();
4556
4557        assert_eq!(
4558            store.show("orig123").unwrap().disposition,
4559            crate::ledger::Disposition::Resolved
4560        );
4561        let history = store.read_history().unwrap();
4562        let petition_entry = history
4563            .iter()
4564            .find(|entry| {
4565                entry.petition_for.as_deref() == Some("orig123") && entry.commit_sha == "fix456"
4566            })
4567            .unwrap();
4568        assert_eq!(petition_entry.verdict, Verdict::Flag);
4569    }
4570}