Skip to main content

truth_mirror/
reviewer.rs

1//! Reviewer process harness, model opposition, async queue, and verdict execution.
2
3use std::{
4    fs,
5    io::{self, Write},
6    path::{Path, PathBuf},
7    process::{Command, ExitCode, Stdio},
8    time::{SystemTime, UNIX_EPOCH},
9};
10
11use anyhow::Result;
12use serde::{Deserialize, Serialize};
13use thiserror::Error;
14
15use crate::{
16    claim::{Claim, EvidenceRef},
17    cli::{self, Agent, ReviewScope, ReviewerHarness},
18    config::{self, Effort},
19    ledger::{
20        LedgerEntry, LedgerStore, MemorySkillClassification, ReviewerConfig, StructuredFinding,
21        Verdict,
22    },
23    provenance::{self, EntireCheckpointRef},
24    surface,
25    time::unix_now,
26};
27
28pub const REVIEW_QUEUE_FILE: &str = "review-queue.jsonl";
29pub const REVIEW_RUNS_DIR: &str = "runs";
30const MAX_INLINE_DIFF_FILES: usize = 2;
31const MAX_INLINE_DIFF_BYTES: usize = 256 * 1024;
32const MAX_UNTRACKED_FILE_BYTES: u64 = 16 * 1024;
33
34#[derive(Clone, Debug, Eq, PartialEq)]
35pub struct ReviewRequest {
36    pub watched_agent: Agent,
37    pub watched_model: String,
38    pub reviewer_harness: ReviewerHarness,
39    pub reviewer_model: String,
40    pub reviewer_effort: Effort,
41    pub allow_same_model: bool,
42    pub prompt: String,
43}
44
45impl ReviewRequest {
46    pub fn new(
47        watched_agent: Agent,
48        watched_model: impl Into<String>,
49        reviewer_harness: ReviewerHarness,
50        reviewer_model: impl Into<String>,
51        allow_same_model: bool,
52        prompt: impl Into<String>,
53    ) -> Self {
54        Self {
55            watched_agent,
56            watched_model: watched_model.into(),
57            reviewer_harness,
58            reviewer_model: reviewer_model.into(),
59            reviewer_effort: Effort::highest(),
60            allow_same_model,
61            prompt: prompt.into(),
62        }
63    }
64
65    pub fn with_effort(mut self, effort: Effort) -> Self {
66        self.reviewer_effort = effort;
67        self
68    }
69}
70
71/// Resolved reviewer selection shared by `review` and `watch`. The reviewer is
72/// chosen from the writer harness's adversarial pair; explicit CLI values win.
73#[derive(Clone, Debug, Eq, PartialEq)]
74pub struct ReviewSelection {
75    pub watched_agent: Agent,
76    pub watched_model: String,
77    pub reviewer_harness: ReviewerHarness,
78    pub reviewer_model: String,
79    pub reviewer_effort: Effort,
80    pub allow_same_model: bool,
81    pub strict: Option<StrictReviewConfig>,
82}
83
84impl ReviewSelection {
85    /// Resolve the reviewer for a writer harness from its adversarial pair,
86    /// applying any explicit CLI overrides. `strict` is set separately.
87    #[allow(clippy::too_many_arguments)]
88    pub fn resolve(
89        watched_agent: Option<Agent>,
90        watched_model: Option<String>,
91        reviewer_harness: Option<ReviewerHarness>,
92        reviewer_model: Option<String>,
93        reviewer_effort: Option<Effort>,
94        allow_same_model: bool,
95        config: &config::TruthMirrorConfig,
96    ) -> Result<Self, ReviewerError> {
97        let watched_agent = match watched_agent {
98            Some(agent) => agent,
99            None => agent_from_slug(&config.default_writer)?,
100        };
101        let writer_slug = surface::agent_slug(watched_agent);
102        let pair = config.pair_for(writer_slug);
103
104        let harness_from_cli = reviewer_harness.is_some();
105        let reviewer_harness = match reviewer_harness {
106            Some(harness) => harness,
107            None => {
108                let slug = pair
109                    .map(|pair| pair.reviewer.harness.as_str())
110                    .ok_or_else(|| ReviewerError::NoPairForWriter {
111                        writer: writer_slug.to_owned(),
112                    })?;
113                harness_from_slug(slug)?
114            }
115        };
116        let reviewer_model = match reviewer_model {
117            Some(model) => model,
118            None => {
119                let pair = pair.ok_or_else(|| ReviewerError::NoPairForWriter {
120                    writer: writer_slug.to_owned(),
121                })?;
122                // Don't pair a CLI-overridden harness with a model string meant for
123                // a different harness — that silently reviews with the wrong tool.
124                if harness_from_cli
125                    && !pair
126                        .reviewer
127                        .harness
128                        .eq_ignore_ascii_case(harness_slug(reviewer_harness))
129                {
130                    return Err(ReviewerError::OverrideNeedsModel {
131                        role: "reviewer".to_owned(),
132                        harness: harness_slug(reviewer_harness).to_owned(),
133                    });
134                }
135                pair.reviewer.model.clone()
136            }
137        };
138        let reviewer_effort = reviewer_effort
139            .or_else(|| pair.map(|pair| pair.reviewer.effort))
140            .unwrap_or_else(Effort::highest);
141
142        Ok(Self {
143            watched_agent,
144            watched_model: watched_model.unwrap_or_default(),
145            reviewer_harness,
146            reviewer_model,
147            reviewer_effort,
148            // Either the CLI flag or config may waive model opposition.
149            allow_same_model: allow_same_model || config.allow_same_model,
150            strict: None,
151        })
152    }
153
154    /// Resolve the second-pass arbiter for the writer, preferring CLI overrides,
155    /// then the writer pair's `arbiter`.
156    pub fn resolve_arbiter(
157        watched_agent: Agent,
158        arbiter_harness: Option<ReviewerHarness>,
159        arbiter_model: Option<String>,
160        arbiter_effort: Option<Effort>,
161        config: &config::TruthMirrorConfig,
162    ) -> Result<StrictReviewConfig, ReviewerError> {
163        let pair_arbiter = config
164            .pair_for(surface::agent_slug(watched_agent))
165            .and_then(|pair| pair.arbiter.clone());
166
167        let harness_from_cli = arbiter_harness.is_some();
168        let harness = match arbiter_harness {
169            Some(harness) => harness,
170            None => {
171                let slug = pair_arbiter
172                    .as_ref()
173                    .map(|arbiter| arbiter.harness.as_str())
174                    .ok_or(ReviewerError::MissingArbiter)?;
175                harness_from_slug(slug)?
176            }
177        };
178        let model = match arbiter_model {
179            Some(model) => model,
180            None => {
181                let arbiter = pair_arbiter.as_ref().ok_or(ReviewerError::MissingArbiter)?;
182                if harness_from_cli && !arbiter.harness.eq_ignore_ascii_case(harness_slug(harness))
183                {
184                    return Err(ReviewerError::OverrideNeedsModel {
185                        role: "arbiter".to_owned(),
186                        harness: harness_slug(harness).to_owned(),
187                    });
188                }
189                arbiter.model.clone()
190            }
191        };
192        let effort = arbiter_effort
193            .or_else(|| pair_arbiter.as_ref().map(|arbiter| arbiter.effort))
194            .unwrap_or_else(Effort::highest);
195
196        Ok(StrictReviewConfig {
197            arbiter_harness: harness,
198            arbiter_model: model,
199            arbiter_effort: effort,
200        })
201    }
202
203    fn request_for(&self, prompt: String) -> ReviewRequest {
204        ReviewRequest::new(
205            self.watched_agent,
206            self.watched_model.clone(),
207            self.reviewer_harness,
208            self.reviewer_model.clone(),
209            self.allow_same_model,
210            prompt,
211        )
212        .with_effort(self.reviewer_effort)
213    }
214}
215
216#[derive(Clone, Debug, Eq, PartialEq)]
217pub struct ReviewPlan {
218    pub watched_agent: Agent,
219    pub watched_model: String,
220    pub reviewer_harness: ReviewerHarness,
221    pub reviewer_model: String,
222    pub allow_same_model: bool,
223    pub invocation: InvocationPlan,
224}
225
226impl ReviewPlan {
227    pub fn build(request: ReviewRequest) -> Result<Self, ReviewerError> {
228        validate_model_present("reviewer", &request.reviewer_model)?;
229
230        // The writer model may be unknown (pair-based selection doesn't require it);
231        // opposition is enforced only when the writer model is actually provided.
232        if request.watched_model.trim().is_empty() {
233            if !request.allow_same_model {
234                // The writer model is unknown so we cannot verify model opposition at
235                // runtime — the review will proceed, but warn so this doesn't go silent.
236                eprintln!(
237                    "{} warning: writer model is unknown; model opposition is only pair-based \
238                     for this review. Pass --watched-model or set reviewer.model in config to \
239                     enforce per-model opposition.",
240                    crate::messages::diagnostic_prefix()
241                );
242            }
243        } else if !request.allow_same_model
244            && normalized_model(&request.watched_model) == normalized_model(&request.reviewer_model)
245        {
246            return Err(ReviewerError::SameModelWithoutWaiver {
247                watched_model: request.watched_model,
248                reviewer_model: request.reviewer_model,
249            });
250        }
251
252        let invocation = InvocationPlan::for_harness(
253            request.reviewer_harness,
254            &request.reviewer_model,
255            request.reviewer_effort,
256        )?;
257
258        Ok(Self {
259            watched_agent: request.watched_agent,
260            watched_model: request.watched_model,
261            reviewer_harness: request.reviewer_harness,
262            reviewer_model: request.reviewer_model,
263            allow_same_model: request.allow_same_model,
264            invocation,
265        })
266    }
267
268    pub fn run_with<R: ProcessRunner>(
269        &self,
270        prompt: &str,
271        runner: &R,
272    ) -> Result<ProcessOutput, ReviewerError> {
273        runner.run(&self.invocation, prompt)
274    }
275
276    fn reviewer_config(&self) -> ReviewerConfig {
277        ReviewerConfig::new(
278            harness_slug(self.reviewer_harness),
279            self.reviewer_model.clone(),
280            self.allow_same_model,
281        )
282    }
283}
284
285#[derive(Clone, Debug, Eq, PartialEq)]
286pub struct InvocationPlan {
287    pub program: String,
288    pub args: Vec<String>,
289    pub prompt_delivery: PromptDelivery,
290}
291
292impl InvocationPlan {
293    pub fn for_harness(
294        harness: ReviewerHarness,
295        model: &str,
296        effort: Effort,
297    ) -> Result<Self, ReviewerError> {
298        validate_model_present("reviewer", model)?;
299        let model = model.trim();
300        let e = effort.as_str();
301
302        // Reasoning-effort flags verified against source: codex ReasoningEffort
303        // (`-c model_reasoning_effort`), pi `--thinking` (cli/args.js:249), claude
304        // `--effort`. Gemini/OpenCode have no effort flag, so effort is omitted.
305        let plan = match harness {
306            ReviewerHarness::Claude => Self {
307                program: "claude".to_owned(),
308                args: vec![
309                    "--print".to_owned(),
310                    "--model".to_owned(),
311                    model.to_owned(),
312                    "--effort".to_owned(),
313                    // Claude has no `minimal`; clamp to a valid level.
314                    effort.claude_value().to_owned(),
315                ],
316                prompt_delivery: PromptDelivery::Stdin,
317            },
318            ReviewerHarness::Codex => Self {
319                program: "codex".to_owned(),
320                args: vec![
321                    "exec".to_owned(),
322                    "-m".to_owned(),
323                    model.to_owned(),
324                    "-c".to_owned(),
325                    format!("model_reasoning_effort={e}"),
326                ],
327                prompt_delivery: PromptDelivery::PositionalArgument,
328            },
329            ReviewerHarness::Pi => Self {
330                program: "pi".to_owned(),
331                args: vec![
332                    "--model".to_owned(),
333                    model.to_owned(),
334                    "--thinking".to_owned(),
335                    e.to_owned(),
336                    // Read-only tools so the reviewer can grep the repo for the whole
337                    // defect class (Codex `exec` / Claude `-p` have read tools already).
338                    "--tools".to_owned(),
339                    "read,grep,find,ls".to_owned(),
340                    "-p".to_owned(),
341                ],
342                prompt_delivery: PromptDelivery::Stdin,
343            },
344            ReviewerHarness::Gemini => Self {
345                program: "gemini".to_owned(),
346                args: vec!["-m".to_owned(), model.to_owned()],
347                prompt_delivery: PromptDelivery::FlagValue("-p".to_owned()),
348            },
349            ReviewerHarness::Opencode => Self {
350                program: "opencode".to_owned(),
351                args: vec!["run".to_owned(), "--model".to_owned(), model.to_owned()],
352                prompt_delivery: PromptDelivery::PositionalArgument,
353            },
354            ReviewerHarness::Custom => return Err(ReviewerError::UnsupportedCustomHarness),
355        };
356
357        Ok(plan)
358    }
359
360    pub fn args_for_prompt(&self, prompt: &str) -> Vec<String> {
361        let mut args = self.args.clone();
362        match &self.prompt_delivery {
363            PromptDelivery::Stdin => {}
364            PromptDelivery::PositionalArgument => args.push(prompt.to_owned()),
365            PromptDelivery::FlagValue(flag) => {
366                args.push(flag.clone());
367                args.push(prompt.to_owned());
368            }
369        }
370        args
371    }
372}
373
374#[derive(Clone, Debug, Eq, PartialEq)]
375pub enum PromptDelivery {
376    Stdin,
377    PositionalArgument,
378    FlagValue(String),
379}
380
381#[derive(Clone, Debug, Eq, PartialEq)]
382pub struct ProcessOutput {
383    pub status_code: Option<i32>,
384    pub stdout: String,
385    pub stderr: String,
386}
387
388pub trait ProcessRunner {
389    fn run(
390        &self,
391        invocation: &InvocationPlan,
392        prompt: &str,
393    ) -> Result<ProcessOutput, ReviewerError>;
394}
395
396#[derive(Clone, Copy, Debug, Default)]
397pub struct StdProcessRunner;
398
399impl ProcessRunner for StdProcessRunner {
400    fn run(
401        &self,
402        invocation: &InvocationPlan,
403        prompt: &str,
404    ) -> Result<ProcessOutput, ReviewerError> {
405        let mut command = Command::new(&invocation.program);
406        command.args(invocation.args_for_prompt(prompt));
407        command.stdout(Stdio::piped()).stderr(Stdio::piped());
408
409        if invocation.prompt_delivery == PromptDelivery::Stdin {
410            command.stdin(Stdio::piped());
411        }
412
413        let mut child = command.spawn().map_err(ReviewerError::Spawn)?;
414        if invocation.prompt_delivery == PromptDelivery::Stdin {
415            let mut stdin = child.stdin.take().ok_or(ReviewerError::MissingStdinPipe)?;
416            stdin
417                .write_all(prompt.as_bytes())
418                .map_err(ReviewerError::WritePrompt)?;
419        }
420
421        let output = child.wait_with_output().map_err(ReviewerError::Wait)?;
422        Ok(ProcessOutput {
423            status_code: output.status.code(),
424            stdout: String::from_utf8_lossy(&output.stdout).into_owned(),
425            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
426        })
427    }
428}
429
430#[derive(Clone, Debug, Eq, PartialEq)]
431pub struct ReviewJob {
432    pub commit_sha: String,
433    pub claim: Claim,
434    pub diff: String,
435    /// Ground-truth constraints + recent trajectory injected into review prompts.
436    pub context: String,
437    pub request: ReviewRequest,
438    pub strict: Option<StrictReviewConfig>,
439    /// 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 with_patterns(evidence_patterns: Vec<String>) -> Self {
1448        Self { evidence_patterns }
1449    }
1450}
1451
1452impl MaterialLoader for GitMaterialLoader {
1453    fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError> {
1454        let message = git_output(["show", "--format=%B", "--no-patch", sha])?;
1455        let diff = git_output(["show", "--format=", "--patch", sha])?;
1456        let claim = if self.evidence_patterns.is_empty() {
1457            Claim::parse(&message)?
1458        } else {
1459            Claim::parse_with(&message, &self.evidence_patterns)?
1460        };
1461        Ok((claim, diff))
1462    }
1463}
1464
1465#[derive(Clone, Debug, Default, Eq, PartialEq)]
1466pub struct DrainReport {
1467    pub reviewed: Vec<String>,
1468    pub ledger_entries: usize,
1469}
1470
1471/// Review every distinct queued commit exactly once, record verdicts, and remove
1472/// each commit from the queue as soon as its review lands. A commit whose review
1473/// errors stays queued for the next drain.
1474pub fn drain_once<R: ProcessRunner, L: MaterialLoader>(
1475    queue: &ReviewQueue,
1476    loader: &L,
1477    selection: &ReviewSelection,
1478    context: &str,
1479    runner: &R,
1480    store: &LedgerStore,
1481    config: &config::TruthMirrorConfig,
1482) -> Result<DrainReport, ReviewerError> {
1483    let pending = queue.pending()?;
1484    let run_store = ReviewRunStore::new(&queue.root);
1485    // Queue identity is (commit_sha, petition_for), NOT the bare SHA: a fix
1486    // commit's own post-commit review and a petition review of that same fix
1487    // (or two petitions for different rejections sharing one fix) are
1488    // distinct jobs — deduping on SHA alone silently cancelled one of them.
1489    let mut seen = std::collections::BTreeSet::new();
1490    let mut order = Vec::new();
1491    for item in &pending {
1492        let identity = (item.commit_sha.clone(), item.petition_for.clone());
1493        if seen.insert(identity) {
1494            order.push(item.clone());
1495        } else if !item.run_id.trim().is_empty() {
1496            match run_store.read(&item.run_id) {
1497                Ok(run) if run.status == ReviewRunStatus::Queued => {
1498                    run_store.cancel_queued(&item.run_id)?;
1499                }
1500                _ => {}
1501            }
1502        }
1503    }
1504
1505    let mut report = DrainReport::default();
1506    for item in order {
1507        let sha = item.commit_sha;
1508        let run_id = if item.run_id.trim().is_empty() {
1509            generate_run_id(&sha)
1510        } else {
1511            item.run_id
1512        };
1513        let target = if item.petition_for.is_some() {
1514            "petition"
1515        } else {
1516            "commit"
1517        };
1518        let run = run_store.ensure_queued(&run_id, &sha, target)?;
1519        if run.status == ReviewRunStatus::Cancelled {
1520            queue.remove_item(&sha, item.petition_for.as_deref())?;
1521            continue;
1522        }
1523        run_store.mark_running(&run_id, "reviewing")?;
1524        let (claim, diff) = loader.load(&sha)?;
1525        // Petition items rebuild the original rejection's payload from the
1526        // ledger so execute_review_job can swap in the petition prompt and
1527        // apply_petition_transition can close (or escalate) the rejection.
1528        let petition = match &item.petition_for {
1529            Some(original_sha) => Some(petition_context_from_ledger(original_sha, &sha, store)?),
1530            None => None,
1531        };
1532        let prompt = first_pass_prompt(&claim, &diff, context);
1533        let job = ReviewJob {
1534            commit_sha: sha.clone(),
1535            claim,
1536            diff,
1537            context: context.to_owned(),
1538            request: selection.request_for(prompt),
1539            strict: selection.strict.clone(),
1540            petition,
1541        };
1542        let execution = match execute_review_job(job, runner, store) {
1543            Ok(execution) => execution,
1544            Err(error) => {
1545                let _ = run_store.mark_failed(&run_id, error.to_string());
1546                return Err(error);
1547            }
1548        };
1549        record_memory_skill_outcome(
1550            &queue.root,
1551            config,
1552            &run_id,
1553            &sha,
1554            "watch-drain",
1555            &execution.entries,
1556        );
1557        report.ledger_entries += execution.entries.len();
1558        run_store.mark_completed(&run_id, execution.entries.len())?;
1559        // Remove exactly THIS item — remove_sha would also delete a sibling
1560        // row for the same SHA with a different petition identity.
1561        queue.remove_item(&sha, item.petition_for.as_deref())?;
1562        report.reviewed.push(sha);
1563    }
1564
1565    Ok(report)
1566}
1567
1568fn record_memory_skill_outcome(
1569    state_dir: &Path,
1570    config: &config::TruthMirrorConfig,
1571    run_id: &str,
1572    commit_sha: &str,
1573    phase: &str,
1574    entries: &[LedgerEntry],
1575) {
1576    if !is_full_git_sha(commit_sha) {
1577        tracing::debug!(
1578            run_id = %run_id,
1579            commit_sha = %commit_sha,
1580            phase = %phase,
1581            "memory-skill extraction skipped for non-commit review target"
1582        );
1583        return;
1584    }
1585    match crate::memory_skill::evaluate_review_completion(state_dir, config, run_id, entries) {
1586        Ok(_) => {}
1587        Err(crate::memory_skill::MemorySkillError::ScanRejected { reason }) => {
1588            tracing::info!(
1589                run_id = %run_id,
1590                commit_sha = %commit_sha,
1591                phase = %phase,
1592                memory_skill_outcome = "scan_rejected",
1593                reason = %reason,
1594                "memory-skill extraction skipped by scan gate"
1595            );
1596        }
1597        Err(error) => {
1598            let error_kind = memory_skill_error_kind(&error);
1599            tracing::warn!(
1600                run_id = %run_id,
1601                commit_sha = %commit_sha,
1602                phase = %phase,
1603                memory_skill_outcome = "failed",
1604                memory_skill_error_kind = error_kind,
1605                error = %error,
1606                "memory-skill extraction failed after review completion"
1607            );
1608        }
1609    }
1610}
1611
1612fn memory_skill_error_kind(error: &crate::memory_skill::MemorySkillError) -> &'static str {
1613    match error {
1614        crate::memory_skill::MemorySkillError::Io(_) => "io",
1615        crate::memory_skill::MemorySkillError::Json(_) => "json",
1616        crate::memory_skill::MemorySkillError::Ledger(_) => "ledger",
1617        crate::memory_skill::MemorySkillError::CandidateNotFound { .. } => "candidate_not_found",
1618        crate::memory_skill::MemorySkillError::AdvisoryNotFound { .. } => "advisory_not_found",
1619        crate::memory_skill::MemorySkillError::EmptyRejectReason => "empty_reject_reason",
1620        crate::memory_skill::MemorySkillError::EmptySupersedeReason => "empty_supersede_reason",
1621        crate::memory_skill::MemorySkillError::SelfSupersede { .. } => "self_supersede",
1622        crate::memory_skill::MemorySkillError::InvalidSupersedeReplacement { .. } => {
1623            "invalid_supersede_replacement"
1624        }
1625        crate::memory_skill::MemorySkillError::InvalidTransition { .. } => "invalid_transition",
1626        crate::memory_skill::MemorySkillError::TransitionLocked { .. } => "transition_locked",
1627        crate::memory_skill::MemorySkillError::UnsafeGlobalWrite { .. } => "unsafe_global_write",
1628        crate::memory_skill::MemorySkillError::UnsafeApprovedPath { .. } => "unsafe_approved_path",
1629        crate::memory_skill::MemorySkillError::UnsafeStatePath { .. } => "unsafe_state_path",
1630        crate::memory_skill::MemorySkillError::ScanRejected { .. } => "scan_rejected",
1631        crate::memory_skill::MemorySkillError::RenderedSkillTooLarge { .. } => {
1632            "rendered_skill_too_large"
1633        }
1634    }
1635}
1636
1637/// Build the ground-truth + trajectory context block for review prompts.
1638/// Best-effort: an unavailable repo or provider yields an empty block.
1639fn review_context(config: &config::TruthMirrorConfig) -> String {
1640    let repo_root = match git_output(["rev-parse", "--show-toplevel"]) {
1641        Ok(root) => PathBuf::from(root.trim()),
1642        Err(_) => return String::new(),
1643    };
1644    let provider = crate::context::trajectory_provider(&repo_root, &config.history);
1645    crate::context::build_review_context(
1646        &repo_root,
1647        &config.ground_truth,
1648        &config.history,
1649        Some(provider.as_ref()),
1650    )
1651    .unwrap_or_default()
1652}
1653
1654pub fn run_watch_command(
1655    args: cli::WatchArgs,
1656    state_dir: &Path,
1657    config: &config::TruthMirrorConfig,
1658) -> Result<ExitCode> {
1659    let selection = ReviewSelection::resolve(
1660        args.watched_agent,
1661        args.watched_model,
1662        args.reviewer_harness,
1663        args.reviewer_model,
1664        args.reviewer_effort,
1665        args.allow_same_model,
1666        config,
1667    )?;
1668    let queue = ReviewQueue::new(state_dir);
1669    let store = LedgerStore::new(state_dir);
1670    let loader = GitMaterialLoader::with_patterns(config.gates.to_policy().evidence_patterns);
1671    let runner = StdProcessRunner;
1672
1673    if args.once {
1674        let context = review_context(config);
1675        let report = drain_once(
1676            &queue, &loader, &selection, &context, &runner, &store, config,
1677        )?;
1678        println!(
1679            "truth-mirror watch: reviewed {} commit(s), wrote {} ledger entrie(s)",
1680            report.reviewed.len(),
1681            report.ledger_entries
1682        );
1683        return Ok(ExitCode::SUCCESS);
1684    }
1685
1686    let interval = std::time::Duration::from_secs(args.poll_secs.max(1));
1687
1688    if args.until_empty {
1689        return run_until_empty(
1690            &queue, &loader, &selection, &runner, &store, config, state_dir, args.grace, interval,
1691        );
1692    }
1693
1694    loop {
1695        // Rebuild context each poll so ground truth and trajectory stay current.
1696        let context = review_context(config);
1697        let report = drain_once(
1698            &queue, &loader, &selection, &context, &runner, &store, config,
1699        )?;
1700        if !report.reviewed.is_empty() {
1701            println!(
1702                "truth-mirror watch: reviewed {} commit(s)",
1703                report.reviewed.len()
1704            );
1705        }
1706        std::thread::sleep(interval);
1707    }
1708}
1709
1710/// `--until-empty` decision after a drain: given how long the queue has been
1711/// continuously empty (`empty_for_secs`) and the grace window, decide whether the
1712/// watcher should exit.
1713///
1714/// Pure so the grace-window policy is unit-testable without real clocks. `None`
1715/// means the queue is not empty (keep draining); `Some(elapsed)` carries how long
1716/// it has been empty so far.
1717#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1718pub enum UntilEmptyDecision {
1719    /// The queue has items, or the empty window has not yet spanned the grace
1720    /// period — keep polling.
1721    KeepWatching,
1722    /// The queue stayed empty through a full grace window — exit 0.
1723    Exit,
1724}
1725
1726pub fn until_empty_decision(empty_for_secs: Option<u64>, grace_secs: u64) -> UntilEmptyDecision {
1727    match empty_for_secs {
1728        Some(elapsed) if elapsed >= grace_secs => UntilEmptyDecision::Exit,
1729        _ => UntilEmptyDecision::KeepWatching,
1730    }
1731}
1732
1733#[derive(Clone, Debug, Eq, PartialEq)]
1734enum QueueFingerprint {
1735    Missing,
1736    Present {
1737        len: u64,
1738        modified: Option<SystemTime>,
1739    },
1740}
1741
1742#[derive(Clone, Debug, Default)]
1743struct QueueEmptyCache {
1744    fingerprint: Option<QueueFingerprint>,
1745    pending_count: Option<usize>,
1746}
1747
1748fn queue_fingerprint(queue: &ReviewQueue) -> Result<QueueFingerprint, ReviewerError> {
1749    match fs::metadata(queue.path()) {
1750        Ok(metadata) => Ok(QueueFingerprint::Present {
1751            len: metadata.len(),
1752            modified: metadata.modified().ok(),
1753        }),
1754        Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(QueueFingerprint::Missing),
1755        Err(error) => Err(ReviewerError::QueueIo(error)),
1756    }
1757}
1758
1759fn queue_has_pending_cached(
1760    queue: &ReviewQueue,
1761    cache: &mut QueueEmptyCache,
1762) -> Result<bool, ReviewerError> {
1763    let fingerprint = queue_fingerprint(queue)?;
1764    if matches!(
1765        fingerprint,
1766        QueueFingerprint::Missing | QueueFingerprint::Present { len: 0, .. }
1767    ) {
1768        cache.fingerprint = Some(fingerprint);
1769        cache.pending_count = Some(0);
1770        return Ok(false);
1771    }
1772
1773    if let (true, Some(pending_count)) = (
1774        cache.fingerprint.as_ref() == Some(&fingerprint),
1775        cache.pending_count,
1776    ) {
1777        return Ok(pending_count > 0);
1778    }
1779
1780    let pending_count = queue.summary()?.pending_count;
1781    cache.fingerprint = Some(fingerprint);
1782    cache.pending_count = Some(pending_count);
1783    Ok(pending_count > 0)
1784}
1785
1786/// Drain the queue until it stays empty through a full grace window, then exit 0.
1787///
1788/// The watcher records its own identity in the single-flight lock on entry and
1789/// releases it on exit, so `ensure-watcher` can tell a live watcher from a dead
1790/// one. Late arrivals inside the grace window reset the empty timer, so a claim
1791/// enqueued a second before the watcher would have exited is still picked up.
1792#[allow(clippy::too_many_arguments)]
1793fn run_until_empty<R: ProcessRunner, L: MaterialLoader>(
1794    queue: &ReviewQueue,
1795    loader: &L,
1796    selection: &ReviewSelection,
1797    runner: &R,
1798    store: &LedgerStore,
1799    config: &config::TruthMirrorConfig,
1800    state_dir: &Path,
1801    grace_secs: u64,
1802    interval: std::time::Duration,
1803) -> Result<ExitCode> {
1804    // Adopt ownership of the single-flight lock under THIS process's identity, so
1805    // liveness probes point at the actual draining process (not the short-lived
1806    // `ensure-watcher` parent that spawned us).
1807    match crate::watcher::try_acquire_lock(state_dir) {
1808        Ok(crate::watcher::LockClaim::Acquired) => {}
1809        Ok(crate::watcher::LockClaim::HeldByLiveWatcher) => {
1810            eprintln!(
1811                "truth-mirror watch: watcher lock is already held; continuing without lock ownership"
1812            );
1813        }
1814        Err(error) => {
1815            eprintln!(
1816                "truth-mirror watch: failed to acquire watcher lock ({error}); continuing without lock ownership"
1817            );
1818        }
1819    }
1820
1821    let outcome = (|| -> Result<()> {
1822        let mut empty_since: Option<u64> = None;
1823        let mut queue_empty_cache = QueueEmptyCache::default();
1824        loop {
1825            let context = review_context(config);
1826            let report = drain_once(queue, loader, selection, &context, runner, store, config)?;
1827            if !report.reviewed.is_empty() {
1828                println!(
1829                    "truth-mirror watch: reviewed {} commit(s)",
1830                    report.reviewed.len()
1831                );
1832            }
1833
1834            let now = unix_now();
1835            if !queue_has_pending_cached(queue, &mut queue_empty_cache)? {
1836                let started = *empty_since.get_or_insert(now);
1837                let empty_for = now.saturating_sub(started);
1838                if until_empty_decision(Some(empty_for), grace_secs) == UntilEmptyDecision::Exit {
1839                    return Ok(());
1840                }
1841            } else {
1842                // A late arrival landed — reset the grace timer.
1843                empty_since = None;
1844            }
1845
1846            std::thread::sleep(interval);
1847        }
1848    })();
1849
1850    // Always release our lock, even on error, so a crashed drain never wedges the
1851    // next spawn (the staleness check would recover it anyway, but this is tidy).
1852    let _ = crate::watcher::release_lock_if_owned(state_dir);
1853    outcome?;
1854    Ok(ExitCode::SUCCESS)
1855}
1856
1857/// `ensure-watcher`: idempotent check-and-spawn of the queue-tied watcher.
1858///
1859/// Fast and quiet on success (safe to call from a git hook after every enqueue).
1860/// Prints one line only when it actually starts a watcher, so hook output stays
1861/// clean when a watcher already exists.
1862pub fn run_ensure_watcher_command(
1863    _args: cli::EnsureWatcherArgs,
1864    state_dir: &Path,
1865) -> Result<ExitCode> {
1866    match crate::watcher::ensure_watcher(state_dir)? {
1867        crate::watcher::LockClaim::Acquired => {
1868            println!("truth-mirror: watcher started");
1869        }
1870        crate::watcher::LockClaim::HeldByLiveWatcher => {
1871            tracing::debug!("ensure-watcher: live watcher already owns the state dir");
1872        }
1873    }
1874    Ok(ExitCode::SUCCESS)
1875}
1876
1877#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1878pub struct StrictGoalPolicy {
1879    pub stop_after_lies: u32,
1880    pub stop_after_fuckups: u32,
1881}
1882
1883#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1884pub struct StrictGoalCounters {
1885    pub lies_exposed: u32,
1886    pub fuckups_registered: u32,
1887}
1888
1889#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1890pub enum StrictGoalDecision {
1891    Continue,
1892    Stop { reason: StrictGoalStopReason },
1893}
1894
1895#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1896pub enum StrictGoalStopReason {
1897    LiesExposed,
1898    FuckupsRegistered,
1899}
1900
1901impl StrictGoalPolicy {
1902    pub fn decide(&self, counters: StrictGoalCounters) -> StrictGoalDecision {
1903        if self.stop_after_lies > 0 && counters.lies_exposed >= self.stop_after_lies {
1904            return StrictGoalDecision::Stop {
1905                reason: StrictGoalStopReason::LiesExposed,
1906            };
1907        }
1908
1909        if self.stop_after_fuckups > 0 && counters.fuckups_registered >= self.stop_after_fuckups {
1910            return StrictGoalDecision::Stop {
1911                reason: StrictGoalStopReason::FuckupsRegistered,
1912            };
1913        }
1914
1915        StrictGoalDecision::Continue
1916    }
1917}
1918
1919#[derive(Clone, Debug, Eq, PartialEq)]
1920pub struct StrictGoalOutcome {
1921    pub passes: u32,
1922    pub counters: StrictGoalCounters,
1923    /// `None` means the loop stopped at the `max_passes` ceiling rather than
1924    /// hitting a configured lie/fuckup threshold.
1925    pub stop_reason: Option<StrictGoalStopReason>,
1926    pub entries: Vec<LedgerEntry>,
1927}
1928
1929impl StrictGoalOutcome {
1930    pub fn stop_reason_suffix(&self) -> &'static str {
1931        match self.stop_reason {
1932            Some(StrictGoalStopReason::LiesExposed) => " (stopped: lies exposed)",
1933            Some(StrictGoalStopReason::FuckupsRegistered) => " (stopped: fuckups registered)",
1934            None => " (stopped: max passes)",
1935        }
1936    }
1937}
1938
1939/// Sic the adversarial reviewer on a commit in a loop, accumulating exposed lies
1940/// (REJECT verdicts) and registered fuckups (individual findings). Every pass is
1941/// recorded in the ledger. The loop stops when `policy` says the configured `N`
1942/// is reached, or when `max_passes` is hit so an honest agent still terminates.
1943#[allow(clippy::too_many_arguments)]
1944pub fn run_strict_goal_loop<R: ProcessRunner>(
1945    commit_sha: &str,
1946    claim: &Claim,
1947    diff: &str,
1948    context: &str,
1949    selection: &ReviewSelection,
1950    policy: StrictGoalPolicy,
1951    max_passes: u32,
1952    runner: &R,
1953    store: &LedgerStore,
1954) -> Result<StrictGoalOutcome, ReviewerError> {
1955    let ceiling = max_passes.max(1);
1956    let mut outcome = StrictGoalOutcome {
1957        passes: 0,
1958        counters: StrictGoalCounters {
1959            lies_exposed: 0,
1960            fuckups_registered: 0,
1961        },
1962        stop_reason: None,
1963        entries: Vec::new(),
1964    };
1965
1966    while outcome.passes < ceiling {
1967        let prompt = strict_goal_prompt(claim, diff, context, outcome.passes + 1, &outcome.entries);
1968        let request = selection.request_for(prompt);
1969        let plan = ReviewPlan::build(request.clone())?;
1970        let output = plan.run_with(&request.prompt, runner)?;
1971        ensure_process_success(&output)?;
1972        let verdict = ParsedVerdict::parse(&output.stdout)?;
1973
1974        let job = ReviewJob {
1975            commit_sha: commit_sha.to_owned(),
1976            claim: claim.clone(),
1977            diff: diff.to_owned(),
1978            context: context.to_owned(),
1979            request,
1980            strict: None,
1981            petition: None,
1982        };
1983        let entry = entry_from_verdict(&job, &plan, &verdict);
1984        store.append_entry(&entry)?;
1985        outcome.entries.push(entry);
1986
1987        outcome.passes += 1;
1988        if verdict.verdict == Verdict::Reject {
1989            outcome.counters.lies_exposed += 1;
1990        }
1991        outcome.counters.fuckups_registered = outcome
1992            .counters
1993            .fuckups_registered
1994            .saturating_add(u32::try_from(verdict.findings.len()).unwrap_or(u32::MAX));
1995
1996        if let StrictGoalDecision::Stop { reason } = policy.decide(outcome.counters) {
1997            outcome.stop_reason = Some(reason);
1998            break;
1999        }
2000    }
2001
2002    Ok(outcome)
2003}
2004
2005fn strict_goal_prompt(
2006    claim: &Claim,
2007    diff: &str,
2008    context: &str,
2009    pass: u32,
2010    prior: &[LedgerEntry],
2011) -> String {
2012    let prior_findings: Vec<String> = prior
2013        .iter()
2014        .flat_map(|entry| entry.findings.clone())
2015        .collect();
2016    let prior_block = if prior_findings.is_empty() {
2017        "(none)".to_owned()
2018    } else {
2019        prior_findings.join("\n")
2020    };
2021    format!(
2022        "{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{}",
2023        context_block(context),
2024        claim.to_line(),
2025        diff
2026    )
2027}
2028
2029pub fn run_review_command(
2030    args: cli::ReviewArgs,
2031    state_dir: &Path,
2032    config: &config::TruthMirrorConfig,
2033) -> Result<ExitCode> {
2034    if let Some(command) = args.command {
2035        return run_review_run_command(command, state_dir);
2036    }
2037
2038    let material = ReviewMaterial::load(
2039        &args,
2040        state_dir,
2041        &config.gates.to_policy().evidence_patterns,
2042    )?;
2043
2044    let mut selection = ReviewSelection::resolve(
2045        args.watched_agent,
2046        args.watched_model,
2047        args.reviewer_harness,
2048        args.reviewer_model,
2049        args.reviewer_effort,
2050        args.allow_same_model,
2051        config,
2052    )?;
2053
2054    if args.strict_two_pass {
2055        selection.strict = Some(ReviewSelection::resolve_arbiter(
2056            selection.watched_agent,
2057            args.arbiter_harness,
2058            args.arbiter_model,
2059            args.arbiter_effort,
2060            config,
2061        )?);
2062    }
2063    let store = LedgerStore::new(state_dir);
2064    let run_store = ReviewRunStore::new(state_dir);
2065    let context = review_context(config);
2066    let run = run_store.create_queued(&material.commit_sha, material.target_label.clone())?;
2067    run_store.mark_running(&run.id, "reviewing")?;
2068
2069    if args.strict_goal {
2070        let policy = config
2071            .strict
2072            .goal_policy(args.stop_after_lies, args.stop_after_fuckups);
2073        let max_passes = args.max_passes.unwrap_or(config.strict.max_passes);
2074        let outcome = match run_strict_goal_loop(
2075            &material.commit_sha,
2076            &material.claim,
2077            &material.diff,
2078            &context,
2079            &selection,
2080            policy,
2081            max_passes,
2082            &StdProcessRunner,
2083            &store,
2084        ) {
2085            Ok(outcome) => outcome,
2086            Err(error) => {
2087                let _ = run_store.mark_failed(&run.id, error.to_string());
2088                return Err(error.into());
2089            }
2090        };
2091        record_memory_skill_outcome(
2092            state_dir,
2093            config,
2094            &run.id,
2095            &material.commit_sha,
2096            "strict-goal",
2097            &outcome.entries,
2098        );
2099        run_store.mark_completed(&run.id, outcome.entries.len())?;
2100        println!(
2101            "truth-mirror strict-goal: run {}, {} pass(es), {} lie(s), {} fuckup(s){}",
2102            run.id,
2103            outcome.passes,
2104            outcome.counters.lies_exposed,
2105            outcome.counters.fuckups_registered,
2106            outcome.stop_reason_suffix(),
2107        );
2108        return Ok(ExitCode::SUCCESS);
2109    }
2110
2111    let prompt = first_pass_prompt(&material.claim, &material.diff, &context);
2112    let commit_sha = material.commit_sha.clone();
2113    let job = ReviewJob {
2114        commit_sha: material.commit_sha,
2115        claim: material.claim,
2116        diff: material.diff,
2117        context,
2118        request: selection.request_for(prompt),
2119        strict: selection.strict.clone(),
2120        petition: None,
2121    };
2122
2123    let execution = match execute_review_job(job, &StdProcessRunner, &store) {
2124        Ok(execution) => execution,
2125        Err(error) => {
2126            let _ = run_store.mark_failed(&run.id, error.to_string());
2127            return Err(error.into());
2128        }
2129    };
2130    record_memory_skill_outcome(
2131        state_dir,
2132        config,
2133        &run.id,
2134        &commit_sha,
2135        "manual-review",
2136        &execution.entries,
2137    );
2138    run_store.mark_completed(&run.id, execution.entries.len())?;
2139    println!(
2140        "truth-mirror review: run {}, wrote {} ledger entrie(s)",
2141        run.id,
2142        execution.entries.len()
2143    );
2144    Ok(ExitCode::SUCCESS)
2145}
2146
2147fn run_review_run_command(command: cli::ReviewCommand, state_dir: &Path) -> Result<ExitCode> {
2148    let runs = ReviewRunStore::new(state_dir);
2149    match command {
2150        cli::ReviewCommand::Status { run_id } => {
2151            if let Some(run_id) = run_id {
2152                print_run(&runs.read_reconciled(&run_id)?);
2153            } else {
2154                let all = runs.list_reconciled()?;
2155                if all.is_empty() {
2156                    println!("No review runs.");
2157                } else {
2158                    for run in all {
2159                        print_run_summary(&run);
2160                    }
2161                }
2162            }
2163        }
2164        cli::ReviewCommand::Result { run_id } => {
2165            let run = match run_id {
2166                Some(run_id) => runs.read(&run_id)?,
2167                None => runs.latest_result()?,
2168            };
2169            print_run(&run);
2170            print_run_ledger_entries(state_dir, &run)?;
2171        }
2172        cli::ReviewCommand::Cancel { run_id, force } => {
2173            let run = runs.cancel(&run_id, force)?;
2174            ReviewQueue::new(state_dir).remove_run_id(&run_id)?;
2175            match run.status {
2176                ReviewRunStatus::Failed => println!(
2177                    "reaped stale review run {} ({}): {}",
2178                    run.id,
2179                    run.commit_sha,
2180                    run.error.as_deref().unwrap_or("worker was not alive"),
2181                ),
2182                _ => println!("cancelled review run {} ({})", run.id, run.commit_sha),
2183            }
2184        }
2185    }
2186    Ok(ExitCode::SUCCESS)
2187}
2188
2189fn print_run_summary(run: &ReviewRun) {
2190    println!(
2191        "{} {} {} {} entries={} updated={}",
2192        run.id, run.status, run.commit_sha, run.phase, run.ledger_entries, run.updated_at_unix
2193    );
2194}
2195
2196fn print_run(run: &ReviewRun) {
2197    println!("run: {}", run.id);
2198    println!("status: {}", run.status);
2199    println!("commit: {}", run.commit_sha);
2200    println!("target: {}", run.target);
2201    println!("phase: {}", run.phase);
2202    println!("ledger_entries: {}", run.ledger_entries);
2203    if let Some(pid) = run.worker_pid {
2204        println!("worker_pid: {pid}");
2205    }
2206    println!("created_at_unix: {}", run.created_at_unix);
2207    println!("updated_at_unix: {}", run.updated_at_unix);
2208    if let Some(started) = run.started_at_unix {
2209        println!("started_at_unix: {started}");
2210    }
2211    if let Some(completed) = run.completed_at_unix {
2212        println!("completed_at_unix: {completed}");
2213    }
2214    if let Some(error) = &run.error {
2215        println!("error: {error}");
2216    }
2217    if let Some(checkpoint) = &run.entire_checkpoint {
2218        println!("entire_ref: {}", checkpoint.ref_name);
2219        println!("entire_sha: {}", checkpoint.object_sha);
2220    }
2221}
2222
2223fn print_run_ledger_entries(state_dir: &Path, run: &ReviewRun) -> Result<(), ReviewerError> {
2224    let store = LedgerStore::new(state_dir);
2225    let entries: Vec<LedgerEntry> = store
2226        .read_history()?
2227        .into_iter()
2228        .filter(|entry| entry.commit_sha == run.commit_sha)
2229        .collect();
2230    if entries.is_empty() {
2231        println!("ledger_entries: none");
2232        return Ok(());
2233    }
2234    println!("ledger_entries:");
2235    for entry in entries {
2236        println!(
2237            "- {} {} {} findings={}",
2238            entry.commit_sha,
2239            entry.verdict,
2240            entry.disposition,
2241            entry.findings.len()
2242        );
2243    }
2244    Ok(())
2245}
2246
2247#[derive(Clone, Debug, Eq, PartialEq)]
2248struct ReviewMaterial {
2249    commit_sha: String,
2250    target_label: String,
2251    claim: Claim,
2252    diff: String,
2253}
2254
2255impl ReviewMaterial {
2256    fn load(
2257        args: &cli::ReviewArgs,
2258        state_dir: &Path,
2259        evidence_patterns: &[String],
2260    ) -> Result<Self, ReviewerError> {
2261        let parse = |text: &str| {
2262            if evidence_patterns.is_empty() {
2263                Claim::parse(text)
2264            } else {
2265                Claim::parse_with(text, evidence_patterns)
2266            }
2267        };
2268
2269        let scope = if args.staged {
2270            ReviewScope::Staged
2271        } else {
2272            args.scope
2273        };
2274
2275        match scope {
2276            ReviewScope::Commit => {
2277                let target = args
2278                    .target
2279                    .clone()
2280                    .ok_or(ReviewerError::MissingReviewTarget)?;
2281                let sha = resolve_commit_target(&target)?;
2282                let message = git_output(["show", "--format=%B", "--no-patch", sha.as_str()])?;
2283                let diff = git_output(["show", "--format=", "--patch", sha.as_str()])?;
2284                let claim = parse(&message)?;
2285                Ok(Self {
2286                    commit_sha: sha.clone(),
2287                    target_label: format!("commit:{target}"),
2288                    claim,
2289                    diff,
2290                })
2291            }
2292            ReviewScope::Staged => Self::load_staged(state_dir, &parse),
2293            ReviewScope::Auto => {
2294                reject_target_with_scope(args)?;
2295                if working_tree_dirty()? {
2296                    Self::load_working_tree(state_dir, &parse)
2297                } else {
2298                    Self::load_branch(args.base.as_deref(), &parse)
2299                }
2300            }
2301            ReviewScope::WorkingTree => {
2302                reject_target_with_scope(args)?;
2303                Self::load_working_tree(state_dir, &parse)
2304            }
2305            ReviewScope::Branch => {
2306                reject_target_with_scope(args)?;
2307                Self::load_branch(args.base.as_deref(), &parse)
2308            }
2309        }
2310    }
2311
2312    fn load_staged<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
2313    where
2314        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2315    {
2316        let raw = git_output(["diff", "--cached"])?;
2317        let files = git_output(["diff", "--cached", "--name-only"])?;
2318        let diff = materialize_diff("staged", &raw, &files);
2319        let claim = parse(&read_claim_file(state_dir)?)?;
2320        Ok(Self {
2321            commit_sha: "STAGED".to_owned(),
2322            target_label: "staged".to_owned(),
2323            claim,
2324            diff,
2325        })
2326    }
2327
2328    fn load_working_tree<F>(state_dir: &Path, parse: &F) -> Result<Self, ReviewerError>
2329    where
2330        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2331    {
2332        let status = git_output(["status", "--porcelain"])?;
2333        let tracked = git_output(["diff", "HEAD", "--patch"])?;
2334        let files = git_output(["diff", "HEAD", "--name-only"])?;
2335        let untracked = untracked_file_context()?;
2336        let raw = format!(
2337            "WORKING TREE STATUS:\n{status}\n\nTRACKED DIFF AGAINST HEAD:\n{tracked}\n\nUNTRACKED FILES:\n{untracked}"
2338        );
2339        let diff = materialize_diff("working-tree", &raw, &files);
2340        let claim = parse(&read_claim_file(state_dir)?)?;
2341        Ok(Self {
2342            commit_sha: "WORKING_TREE".to_owned(),
2343            target_label: "working-tree".to_owned(),
2344            claim,
2345            diff,
2346        })
2347    }
2348
2349    fn load_branch<F>(base: Option<&str>, parse: &F) -> Result<Self, ReviewerError>
2350    where
2351        F: Fn(&str) -> Result<Claim, crate::claim::ClaimError>,
2352    {
2353        let base = match base {
2354            Some(base) => base.to_owned(),
2355            None => default_branch_ref()?,
2356        };
2357        let merge_base = git_output_slice(&["merge-base", "HEAD", &base])?;
2358        let merge_base = merge_base.trim().to_owned();
2359        let range = format!("{merge_base}..HEAD");
2360        let message = git_output(["show", "--format=%B", "--no-patch", "HEAD"])?;
2361        let log = git_output_slice(&["log", "--oneline", &range])?;
2362        let stat = git_output_slice(&["diff", "--stat", &range])?;
2363        let raw_patch = git_output_slice(&["diff", "--patch", &range])?;
2364        let files = git_output_slice(&["diff", "--name-only", &range])?;
2365        let raw = format!(
2366            "BRANCH BASE: {base}\nMERGE BASE: {merge_base}\nCOMMITS:\n{log}\n\nDIFF STAT:\n{stat}\n\nDIFF:\n{raw_patch}"
2367        );
2368        let diff = materialize_diff(&format!("branch:{base}"), &raw, &files);
2369        let claim = parse(&message)?;
2370        Ok(Self {
2371            commit_sha: "HEAD".to_owned(),
2372            target_label: format!("branch:{base}"),
2373            claim,
2374            diff,
2375        })
2376    }
2377}
2378
2379fn resolve_commit_target(target: &str) -> Result<String, ReviewerError> {
2380    let rev = format!("{target}^{{commit}}");
2381    Ok(
2382        git_output_slice(&["rev-parse", "--verify", "--quiet", "--end-of-options", &rev])?
2383            .trim()
2384            .to_owned(),
2385    )
2386}
2387
2388fn reject_target_with_scope(args: &cli::ReviewArgs) -> Result<(), ReviewerError> {
2389    if let Some(target) = &args.target {
2390        return Err(ReviewerError::UnexpectedReviewTarget {
2391            scope: args.scope,
2392            target: target.clone(),
2393        });
2394    }
2395    Ok(())
2396}
2397
2398fn read_claim_file(state_dir: &Path) -> Result<String, ReviewerError> {
2399    let claim_path = state_dir.join("claim.txt");
2400    fs::read_to_string(&claim_path).map_err(|source| ReviewerError::ClaimFileRead {
2401        path: claim_path,
2402        source,
2403    })
2404}
2405
2406fn working_tree_dirty() -> Result<bool, ReviewerError> {
2407    Ok(!git_output(["status", "--porcelain"])?.trim().is_empty())
2408}
2409
2410fn default_branch_ref() -> Result<String, ReviewerError> {
2411    if let Ok(symbolic) = git_output([
2412        "symbolic-ref",
2413        "--quiet",
2414        "--short",
2415        "refs/remotes/origin/HEAD",
2416    ]) {
2417        let trimmed = symbolic.trim();
2418        if !trimmed.is_empty() {
2419            return Ok(trimmed.to_owned());
2420        }
2421    }
2422
2423    for candidate in [
2424        "origin/main",
2425        "origin/master",
2426        "origin/trunk",
2427        "main",
2428        "master",
2429        "trunk",
2430    ] {
2431        if git_output_slice(&["rev-parse", "--verify", "--quiet", candidate]).is_ok() {
2432            return Ok(candidate.to_owned());
2433        }
2434    }
2435
2436    Err(ReviewerError::DefaultBranchNotFound)
2437}
2438
2439fn materialize_diff(label: &str, raw: &str, files: &str) -> String {
2440    let file_list: Vec<&str> = files
2441        .lines()
2442        .filter(|line| !line.trim().is_empty())
2443        .collect();
2444    let bytes = raw.len();
2445    if bytes <= MAX_INLINE_DIFF_BYTES && file_list.len() <= MAX_INLINE_DIFF_FILES {
2446        return raw.to_owned();
2447    }
2448
2449    format!(
2450        "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.",
2451        file_list.len(),
2452        if file_list.is_empty() {
2453            "(none)".to_owned()
2454        } else {
2455            file_list.join("\n")
2456        }
2457    )
2458}
2459
2460fn is_full_git_sha(value: &str) -> bool {
2461    matches!(value.len(), 40 | 64) && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2462}
2463
2464fn untracked_file_context() -> Result<String, ReviewerError> {
2465    let files = git_output(["ls-files", "--others", "--exclude-standard"])?;
2466    let mut output = String::new();
2467    for file in files.lines().filter(|line| !line.trim().is_empty()) {
2468        let path = Path::new(file);
2469        let metadata = match fs::metadata(path) {
2470            Ok(metadata) => metadata,
2471            Err(_) => continue,
2472        };
2473        if !metadata.is_file() {
2474            continue;
2475        }
2476        if metadata.len() > MAX_UNTRACKED_FILE_BYTES {
2477            output.push_str(&format!(
2478                "\n--- {file} omitted: {} bytes exceeds {MAX_UNTRACKED_FILE_BYTES} byte inline limit ---\n",
2479                metadata.len()
2480            ));
2481            continue;
2482        }
2483        let bytes = match fs::read(path) {
2484            Ok(bytes) => bytes,
2485            Err(_) => continue,
2486        };
2487        if bytes.contains(&0) {
2488            output.push_str(&format!("\n--- {file} omitted: binary file ---\n"));
2489            continue;
2490        }
2491        output.push_str(&format!(
2492            "\n--- {file} ---\n{}",
2493            String::from_utf8_lossy(&bytes)
2494        ));
2495    }
2496
2497    if output.is_empty() {
2498        Ok("(none)".to_owned())
2499    } else {
2500        Ok(output)
2501    }
2502}
2503
2504#[derive(Debug, Error)]
2505pub enum ReviewerError {
2506    #[error("missing {role} model")]
2507    MissingModel { role: String },
2508    #[error(
2509        "same reviewer model is disallowed without --allow-same-model: watched={watched_model}, reviewer={reviewer_model}"
2510    )]
2511    SameModelWithoutWaiver {
2512        watched_model: String,
2513        reviewer_model: String,
2514    },
2515    #[error("strict arbiter model must differ from watched and first reviewer models")]
2516    StrictArbiterModelNotDistinct,
2517    #[error("no adversarial pair configured for writer harness {writer:?}")]
2518    NoPairForWriter { writer: String },
2519    #[error(
2520        "strict review requires an arbiter (pair.arbiter or --arbiter-harness/--arbiter-model)"
2521    )]
2522    MissingArbiter,
2523    #[error(
2524        "--{role}-harness={harness:?} was overridden without a matching --{role}-model; the pair's model is for a different harness"
2525    )]
2526    OverrideNeedsModel { role: String, harness: String },
2527    #[error("custom reviewer harness requires explicit command configuration")]
2528    UnsupportedCustomHarness,
2529    #[error("unknown watched agent {value:?}")]
2530    UnknownAgent { value: String },
2531    #[error("unknown reviewer harness {value:?}")]
2532    UnknownHarness { value: String },
2533    #[error("missing review target")]
2534    MissingReviewTarget,
2535    #[error("--scope={scope:?} does not accept positional target {target:?}")]
2536    UnexpectedReviewTarget { scope: ReviewScope, target: String },
2537    #[error("could not determine default branch; pass --base explicitly")]
2538    DefaultBranchNotFound,
2539    #[error("failed to read staged claim file {path}: {source}")]
2540    ClaimFileRead {
2541        path: PathBuf,
2542        #[source]
2543        source: io::Error,
2544    },
2545    #[error("reviewer output was not valid structured JSON verdict: {source}: {output:?}")]
2546    VerdictJson {
2547        source: serde_json::Error,
2548        output: String,
2549    },
2550    #[error("reviewer structured verdict violated schema: {message}")]
2551    VerdictSchema { message: String },
2552    #[error("reviewer process exited with status {status:?}: {stderr}")]
2553    ReviewerProcessFailed { status: Option<i32>, stderr: String },
2554    #[error("git command failed: git {args:?}: {stderr}")]
2555    GitFailed { args: Vec<String>, stderr: String },
2556    #[error("failed to spawn git command: {0}")]
2557    GitSpawn(io::Error),
2558    #[error("failed to spawn reviewer process: {0}")]
2559    Spawn(io::Error),
2560    #[error("failed to open reviewer stdin pipe")]
2561    MissingStdinPipe,
2562    #[error("failed to write reviewer prompt: {0}")]
2563    WritePrompt(io::Error),
2564    #[error("failed to wait for reviewer process: {0}")]
2565    Wait(io::Error),
2566    #[error("review queue IO failed: {0}")]
2567    QueueIo(io::Error),
2568    #[error("review queue JSON failed: {0}")]
2569    QueueJson(serde_json::Error),
2570    #[error("review run IO failed: {0}")]
2571    RunIo(io::Error),
2572    #[error("review run JSON failed: {0}")]
2573    RunJson(serde_json::Error),
2574    #[error("review run not found: {id}")]
2575    ReviewRunNotFound { id: String },
2576    #[error("no review runs found")]
2577    NoReviewRuns,
2578    #[error("cannot cancel review run {id} with status {status}; it has already finished")]
2579    CannotCancelReview { id: String, status: ReviewRunStatus },
2580    #[error(
2581        "review run {id} is still running (worker pid {pid} is alive); pass --force to kill it"
2582    )]
2583    ReviewRunStillAlive { id: String, pid: u32 },
2584    #[error(
2585        "review run {id} is running but records no worker pid; pass --force to reap it if it is stuck"
2586    )]
2587    ReviewRunLivenessUnknown { id: String },
2588    #[error("failed to spawn kill for stale worker: {0}")]
2589    KillWorker(io::Error),
2590    #[error("failed to kill worker process {pid}")]
2591    KillWorkerFailed { pid: u32 },
2592    #[error(transparent)]
2593    Claim(#[from] crate::claim::ClaimError),
2594    #[error(transparent)]
2595    Ledger(#[from] crate::ledger::LedgerError),
2596}
2597
2598const 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.
2599
2600Attack 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.
2601
2602GREP 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.
2603
2604Return valid JSON only. Do not wrap it in Markdown. The schema is:
2605{
2606  "verdict": "PASS" | "REJECT" | "FLAG",
2607  "summary": "one concise sentence explaining why the claim passes or fails",
2608  "findings": [
2609    {
2610      "severity": "critical" | "high" | "medium" | "low",
2611      "title": "short defect title",
2612      "body": "what can go wrong, why this code is vulnerable, and what evidence proves it",
2613      "file": "repo-relative file path",
2614      "line_start": 1,
2615      "line_end": 1,
2616      "confidence": 0,
2617      "recommendation": "concrete change required"
2618    }
2619  ],
2620  "next_steps": ["short concrete follow-up commands or edits"],
2621  "memory_skill": {
2622    "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2623    "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2624    "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2625  }
2626}
2627
2628For "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.
2629
2630Verdict semantics:
2631- "PASS": the claim is substantiated AND there are no findings. Never pair PASS with findings.
2632- "REJECT": at least one finding is materially false / unverified / unaddressed. Must include at least one finding.
2633- "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."#;
2634
2635fn context_block(context: &str) -> String {
2636    if context.trim().is_empty() {
2637        String::new()
2638    } else {
2639        format!("\n\n{context}")
2640    }
2641}
2642
2643fn first_pass_prompt(claim: &Claim, diff: &str, context: &str) -> String {
2644    format!(
2645        "{ADVERSARIAL_PREAMBLE}{}\n\nCLAIM:\n{}\n\nDIFF:\n{}",
2646        context_block(context),
2647        claim.to_line(),
2648        diff
2649    )
2650}
2651
2652const 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.
2653
2654Return valid JSON only. Do not wrap it in Markdown. The schema is:
2655{
2656  "verdict": "PASS" | "REJECT" | "FLAG",
2657  "summary": "one concise sentence explaining whether the fix materially addresses each finding",
2658  "findings": [
2659    {
2660      "severity": "critical" | "high" | "medium" | "low",
2661      "title": "short defect title",
2662      "body": "what still fails after the fix, why this code is still vulnerable, and what evidence proves it",
2663      "file": "repo-relative file path",
2664      "line_start": 1,
2665      "line_end": 1,
2666      "confidence": 0,
2667      "recommendation": "concrete change required"
2668    }
2669  ],
2670  "next_steps": ["short concrete follow-up commands or edits"],
2671  "memory_skill": {
2672    "kind": "none" | "how_to_skill" | "anti_pattern_skill" | "remediation_skill",
2673    "learning_source": "short reusable procedure or failure class; empty only when kind is none",
2674    "reasoning": "why this memory-skill classification applies or why no candidate should be proposed"
2675  }
2676}
2677
2678Verdict semantics:
2679- "PASS" means the fix materially addresses every original finding. Return PASS only when findings is empty.
2680- "REJECT" means at least one finding is still materially unaddressed by the fix.
2681- "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.
2682
2683For "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."#;
2684
2685fn petition_prompt(petition: &PetitionContext, claim: &Claim, diff: &str, context: &str) -> String {
2686    let findings_block = if petition.original_structured_findings.is_empty() {
2687        petition.original_findings.join("\n")
2688    } else {
2689        petition
2690            .original_structured_findings
2691            .iter()
2692            .map(StructuredFinding::display_line)
2693            .collect::<Vec<_>>()
2694            .join("\n")
2695    };
2696    format!(
2697        "{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{}",
2698        context_block(context),
2699        petition.original_sha,
2700        petition.fix_sha,
2701        petition.attempts_so_far,
2702        petition.original_reviewer_model,
2703        petition.original_claim,
2704        petition.original_summary,
2705        if findings_block.trim().is_empty() {
2706            "(none recorded)".to_owned()
2707        } else {
2708            findings_block
2709        },
2710        diff,
2711        claim.to_line(),
2712    )
2713}
2714
2715fn strict_second_pass_prompt(job: &ReviewJob, first_output: &str) -> String {
2716    // A strict pass over a PETITION must stay on the petition question — the
2717    // generic completeness prompt carries the fix commit's claim/diff but not
2718    // the original rejection's findings, so the arbiter would end up judging
2719    // a first review whose question it never saw.
2720    if let Some(petition) = &job.petition {
2721        let petition_question = petition_prompt(petition, &job.claim, &job.diff, &job.context);
2722        return format!(
2723            "{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}"
2724        );
2725    }
2726    format!(
2727        "{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{}",
2728        context_block(&job.context),
2729        job.claim.to_line(),
2730        first_output,
2731        job.diff
2732    )
2733}
2734
2735fn entry_from_verdict(job: &ReviewJob, plan: &ReviewPlan, verdict: &ParsedVerdict) -> LedgerEntry {
2736    let mut entry = LedgerEntry::new(
2737        job.commit_sha.clone(),
2738        verdict.verdict,
2739        job.claim.to_line(),
2740        job.claim
2741            .evidence
2742            .iter()
2743            .map(EvidenceRef::as_str)
2744            .map(str::to_owned)
2745            .collect(),
2746        plan.reviewer_config(),
2747        verdict.findings.clone(),
2748    )
2749    .with_structured_review(
2750        verdict.summary.clone(),
2751        verdict.structured_findings.clone(),
2752        verdict.next_steps.clone(),
2753        verdict.raw.clone(),
2754    )
2755    .with_memory_skill_classification(verdict.memory_skill_classification.clone());
2756    if let Some(petition) = &job.petition {
2757        entry.petition_for = Some(petition.original_sha.clone());
2758        // `attempts_so_far` already includes the in-flight attempt — the CLI
2759        // counted it when it enqueued the petition (see `resolve::run_petition`).
2760        // Adding 1 here would double-count every attempt across CLI + watcher.
2761        entry.petition_attempts = petition.attempts_so_far;
2762    }
2763    entry
2764}
2765
2766fn ensure_process_success(output: &ProcessOutput) -> Result<(), ReviewerError> {
2767    if output.status_code == Some(0) {
2768        return Ok(());
2769    }
2770
2771    Err(ReviewerError::ReviewerProcessFailed {
2772        status: output.status_code,
2773        stderr: output.stderr.clone(),
2774    })
2775}
2776
2777fn validate_strict_arbiter(
2778    request: &ReviewRequest,
2779    strict: &StrictReviewConfig,
2780) -> Result<(), ReviewerError> {
2781    let arbiter = normalized_model(&strict.arbiter_model);
2782    if arbiter == normalized_model(&request.watched_model)
2783        || arbiter == normalized_model(&request.reviewer_model)
2784    {
2785        return Err(ReviewerError::StrictArbiterModelNotDistinct);
2786    }
2787    Ok(())
2788}
2789
2790fn validate_model_present(role: &str, model: &str) -> Result<(), ReviewerError> {
2791    if model.trim().is_empty() {
2792        return Err(ReviewerError::MissingModel {
2793            role: role.to_owned(),
2794        });
2795    }
2796    Ok(())
2797}
2798
2799fn git_output<const N: usize>(args: [&str; N]) -> Result<String, ReviewerError> {
2800    git_output_slice(&args)
2801}
2802
2803fn git_output_slice(args: &[&str]) -> Result<String, ReviewerError> {
2804    let output = Command::new("git")
2805        .args(args)
2806        .output()
2807        .map_err(ReviewerError::GitSpawn)?;
2808    if !output.status.success() {
2809        return Err(ReviewerError::GitFailed {
2810            args: args.iter().map(|arg| (*arg).to_owned()).collect(),
2811            stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
2812        });
2813    }
2814
2815    Ok(String::from_utf8_lossy(&output.stdout).into_owned())
2816}
2817
2818fn agent_from_slug(value: &str) -> Result<Agent, ReviewerError> {
2819    match value.trim().to_ascii_lowercase().as_str() {
2820        "claude" => Ok(Agent::Claude),
2821        "codex" => Ok(Agent::Codex),
2822        "pi" => Ok(Agent::Pi),
2823        "grok" => Ok(Agent::Grok),
2824        _ => Err(ReviewerError::UnknownAgent {
2825            value: value.to_owned(),
2826        }),
2827    }
2828}
2829
2830fn harness_from_slug(value: &str) -> Result<ReviewerHarness, ReviewerError> {
2831    match value.trim().to_ascii_lowercase().as_str() {
2832        "claude" => Ok(ReviewerHarness::Claude),
2833        "codex" => Ok(ReviewerHarness::Codex),
2834        "pi" => Ok(ReviewerHarness::Pi),
2835        "gemini" => Ok(ReviewerHarness::Gemini),
2836        "opencode" => Ok(ReviewerHarness::Opencode),
2837        "custom" => Ok(ReviewerHarness::Custom),
2838        _ => Err(ReviewerError::UnknownHarness {
2839            value: value.to_owned(),
2840        }),
2841    }
2842}
2843
2844fn harness_slug(harness: ReviewerHarness) -> &'static str {
2845    match harness {
2846        ReviewerHarness::Claude => "claude",
2847        ReviewerHarness::Codex => "codex",
2848        ReviewerHarness::Pi => "pi",
2849        ReviewerHarness::Gemini => "gemini",
2850        ReviewerHarness::Opencode => "opencode",
2851        ReviewerHarness::Custom => "custom",
2852    }
2853}
2854
2855/// Normalise a model identifier for opposition comparisons.
2856///
2857/// Delegates to [`crate::config::normalized_model`], which is the single
2858/// authoritative implementation shared by config-time validation and the
2859/// runtime same-model guard here (R6 dedup).
2860fn normalized_model(model: &str) -> String {
2861    config::normalized_model(model)
2862}
2863
2864fn generate_run_id(commit_sha: &str) -> String {
2865    let nanos = SystemTime::now()
2866        .duration_since(UNIX_EPOCH)
2867        .map_or(0, |duration| duration.as_nanos());
2868    let short_sha: String = commit_sha
2869        .chars()
2870        .filter(|character| character.is_ascii_alphanumeric())
2871        .take(12)
2872        .collect();
2873    if short_sha.is_empty() {
2874        format!("{nanos}-{}", std::process::id())
2875    } else {
2876        format!("{nanos}-{}-{short_sha}", std::process::id())
2877    }
2878}
2879
2880#[cfg(test)]
2881mod tests {
2882    use std::{cell::RefCell, collections::VecDeque, fs, process::Command};
2883
2884    use super::normalized_model;
2885
2886    use proptest::prelude::*;
2887
2888    use super::{
2889        InvocationPlan, MaterialLoader, ParsedVerdict, ProcessOutput, ProcessRunner,
2890        PromptDelivery, ReviewJob, ReviewPlan, ReviewQueue, ReviewRequest, ReviewRun,
2891        ReviewRunStatus, ReviewRunStore, ReviewSelection, ReviewerError, StrictGoalCounters,
2892        StrictGoalDecision, StrictGoalPolicy, StrictGoalStopReason, StrictReviewConfig,
2893        UntilEmptyDecision, drain_once, execute_review_job, is_full_git_sha,
2894        run_review_run_command, run_strict_goal_loop, until_empty_decision,
2895    };
2896    use crate::{
2897        claim::{Claim, EvidenceRef},
2898        cli::{Agent, ReviewerHarness},
2899        config::{Effort, TruthMirrorConfig},
2900        ledger::{FindingSeverity, LedgerStore, StructuredFinding, Verdict},
2901        watcher::pid_is_alive,
2902    };
2903
2904    fn pass_json() -> String {
2905        serde_json::json!({
2906            "verdict": "PASS",
2907            "summary": "The claim is substantiated by the diff and evidence.",
2908            "findings": [],
2909            "next_steps": [],
2910            "memory_skill": {
2911                "kind": "how_to_skill",
2912                "learning_source": "run reusable review workflow",
2913                "reasoning": "The passing claim describes a reusable verified procedure."
2914            }
2915        })
2916        .to_string()
2917    }
2918
2919    fn reject_json(title: &str) -> String {
2920        serde_json::json!({
2921            "verdict": "REJECT",
2922            "summary": "The claim is not substantiated.",
2923            "findings": [{
2924                "severity": "high",
2925                "title": title,
2926                "body": "The cited evidence does not prove the claimed behavior.",
2927                "file": "src/lib.rs",
2928                "line_start": 1,
2929                "line_end": 1,
2930                "confidence": 95,
2931                "recommendation": "Provide executable evidence that proves the claim."
2932            }],
2933            "next_steps": ["Run the relevant verification command."],
2934            "memory_skill": {
2935                "kind": "anti_pattern_skill",
2936                "learning_source": title,
2937                "reasoning": "The rejection identifies a reusable false-claim failure class."
2938            }
2939        })
2940        .to_string()
2941    }
2942
2943    #[test]
2944    fn same_harness_different_model_is_valid() {
2945        let request = ReviewRequest::new(
2946            Agent::Codex,
2947            "gpt-5.4",
2948            ReviewerHarness::Codex,
2949            "gpt-5.5",
2950            false,
2951            "review this",
2952        );
2953
2954        let plan = ReviewPlan::build(request).unwrap();
2955
2956        assert_eq!(plan.watched_agent, Agent::Codex);
2957        assert_eq!(plan.reviewer_harness, ReviewerHarness::Codex);
2958        assert_eq!(plan.invocation.program, "codex");
2959    }
2960
2961    #[test]
2962    fn same_model_is_blocked_by_default() {
2963        let request = ReviewRequest::new(
2964            Agent::Codex,
2965            " GPT-5.5 ",
2966            ReviewerHarness::Claude,
2967            "gpt-5.5",
2968            false,
2969            "review this",
2970        );
2971
2972        let error = ReviewPlan::build(request).unwrap_err();
2973
2974        assert!(matches!(
2975            error,
2976            ReviewerError::SameModelWithoutWaiver { .. }
2977        ));
2978    }
2979
2980    #[test]
2981    fn allow_same_model_override_is_deliberate() {
2982        let request = ReviewRequest::new(
2983            Agent::Codex,
2984            "gpt-5.5",
2985            ReviewerHarness::Codex,
2986            "gpt-5.5",
2987            true,
2988            "review this",
2989        );
2990
2991        let plan = ReviewPlan::build(request).unwrap();
2992
2993        assert!(plan.allow_same_model);
2994        assert_eq!(plan.reviewer_model, "gpt-5.5");
2995    }
2996
2997    #[test]
2998    fn provider_mapping_uses_verified_prompt_shapes_and_effort() {
2999        let codex =
3000            InvocationPlan::for_harness(ReviewerHarness::Codex, "gpt-5.5", Effort::Xhigh).unwrap();
3001        assert_eq!(codex.program, "codex");
3002        assert_eq!(
3003            codex.args_for_prompt("prompt"),
3004            [
3005                "exec",
3006                "-m",
3007                "gpt-5.5",
3008                "-c",
3009                "model_reasoning_effort=xhigh",
3010                "prompt"
3011            ]
3012        );
3013
3014        let claude =
3015            InvocationPlan::for_harness(ReviewerHarness::Claude, "opus", Effort::High).unwrap();
3016        assert_eq!(claude.program, "claude");
3017        assert_eq!(claude.prompt_delivery, PromptDelivery::Stdin);
3018        assert_eq!(
3019            claude.args_for_prompt("prompt"),
3020            ["--print", "--model", "opus", "--effort", "high"]
3021        );
3022
3023        let gemini =
3024            InvocationPlan::for_harness(ReviewerHarness::Gemini, "gemini-pro", Effort::Xhigh)
3025                .unwrap();
3026        assert_eq!(
3027            gemini.args_for_prompt("prompt"),
3028            ["-m", "gemini-pro", "-p", "prompt"]
3029        );
3030
3031        let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "openai/gpt-5.5", Effort::Xhigh)
3032            .unwrap();
3033        assert_eq!(pi.prompt_delivery, PromptDelivery::Stdin);
3034        assert_eq!(
3035            pi.args_for_prompt("prompt"),
3036            [
3037                "--model",
3038                "openai/gpt-5.5",
3039                "--thinking",
3040                "xhigh",
3041                "--tools",
3042                "read,grep,find,ls",
3043                "-p"
3044            ]
3045        );
3046    }
3047
3048    #[test]
3049    fn custom_harness_requires_explicit_configuration() {
3050        let error = InvocationPlan::for_harness(ReviewerHarness::Custom, "model", Effort::Xhigh)
3051            .unwrap_err();
3052
3053        assert!(matches!(error, ReviewerError::UnsupportedCustomHarness));
3054    }
3055
3056    #[test]
3057    fn effort_maps_to_each_harness_flag() {
3058        for effort in [
3059            Effort::Minimal,
3060            Effort::Low,
3061            Effort::Medium,
3062            Effort::High,
3063            Effort::Xhigh,
3064        ] {
3065            let e = effort.as_str();
3066
3067            let codex = InvocationPlan::for_harness(ReviewerHarness::Codex, "m", effort).unwrap();
3068            assert!(codex.args.contains(&format!("model_reasoning_effort={e}")));
3069
3070            let claude = InvocationPlan::for_harness(ReviewerHarness::Claude, "m", effort).unwrap();
3071            let claude_idx = claude.args.iter().position(|a| a == "--effort").unwrap();
3072            // Claude has no `minimal`; it clamps to a valid level (`low`).
3073            assert_eq!(claude.args[claude_idx + 1], effort.claude_value());
3074            assert_ne!(claude.args[claude_idx + 1], "minimal");
3075
3076            let pi = InvocationPlan::for_harness(ReviewerHarness::Pi, "m", effort).unwrap();
3077            let pi_idx = pi.args.iter().position(|a| a == "--thinking").unwrap();
3078            assert_eq!(pi.args[pi_idx + 1], e);
3079        }
3080    }
3081
3082    #[test]
3083    fn resolve_picks_configured_reviewer_for_every_writer() {
3084        let config = crate::config::TruthMirrorConfig::default();
3085
3086        let cases = [
3087            (Agent::Codex, ReviewerHarness::Claude, "claude-opus-4-8"),
3088            (Agent::Claude, ReviewerHarness::Codex, "gpt-5.5"),
3089            (Agent::Pi, ReviewerHarness::Codex, "gpt-5.5"),
3090            (Agent::Grok, ReviewerHarness::Claude, "claude-opus-4-8"),
3091        ];
3092
3093        for (writer, reviewer_harness, reviewer_model) in cases {
3094            let selection =
3095                ReviewSelection::resolve(Some(writer), None, None, None, None, false, &config)
3096                    .unwrap();
3097
3098            assert_eq!(selection.reviewer_harness, reviewer_harness);
3099            assert_eq!(selection.reviewer_model, reviewer_model);
3100            assert_eq!(selection.reviewer_effort, Effort::Xhigh);
3101        }
3102    }
3103
3104    #[test]
3105    fn overriding_reviewer_harness_without_model_is_rejected() {
3106        // codex's default pair reviewer is claude; overriding harness to pi with no
3107        // model would pair the pi harness with a claude model string.
3108        let config = crate::config::TruthMirrorConfig::default();
3109        let error = ReviewSelection::resolve(
3110            Some(Agent::Codex),
3111            None,
3112            Some(ReviewerHarness::Pi),
3113            None,
3114            None,
3115            false,
3116            &config,
3117        )
3118        .unwrap_err();
3119
3120        assert!(matches!(error, ReviewerError::OverrideNeedsModel { .. }));
3121    }
3122
3123    #[test]
3124    fn overriding_reviewer_harness_matching_pair_is_ok() {
3125        let config = crate::config::TruthMirrorConfig::default();
3126        let selection = ReviewSelection::resolve(
3127            Some(Agent::Codex),
3128            None,
3129            Some(ReviewerHarness::Claude),
3130            None,
3131            None,
3132            false,
3133            &config,
3134        )
3135        .unwrap();
3136
3137        assert_eq!(selection.reviewer_harness, ReviewerHarness::Claude);
3138        assert_eq!(selection.reviewer_model, "claude-opus-4-8");
3139    }
3140
3141    #[test]
3142    fn config_allow_same_model_waives_opposition() {
3143        let config = crate::config::TruthMirrorConfig {
3144            allow_same_model: true,
3145            ..crate::config::TruthMirrorConfig::default()
3146        };
3147
3148        let selection = ReviewSelection::resolve(
3149            Some(Agent::Codex),
3150            Some("gpt-5.5".to_owned()),
3151            Some(ReviewerHarness::Codex),
3152            Some("gpt-5.5".to_owned()),
3153            None,
3154            false, // CLI flag not set — the config waiver must carry it
3155            &config,
3156        )
3157        .unwrap();
3158
3159        assert!(selection.allow_same_model);
3160        // Same watched+reviewer model builds because the config waiver applies.
3161        assert!(ReviewPlan::build(selection.request_for("review".to_owned())).is_ok());
3162    }
3163
3164    #[test]
3165    fn full_git_sha_accepts_sha1_and_sha256_lengths() {
3166        assert!(is_full_git_sha(&"a".repeat(40)));
3167        assert!(is_full_git_sha(&"b".repeat(64)));
3168        assert!(!is_full_git_sha(&"c".repeat(39)));
3169        assert!(!is_full_git_sha(&"g".repeat(40)));
3170    }
3171
3172    #[test]
3173    fn resolve_arbiter_uses_pair_when_cli_absent() {
3174        let config = crate::config::TruthMirrorConfig::default();
3175        let arbiter =
3176            ReviewSelection::resolve_arbiter(Agent::Codex, None, None, None, &config).unwrap();
3177
3178        assert_eq!(arbiter.arbiter_harness, ReviewerHarness::Pi);
3179        assert_eq!(arbiter.arbiter_effort, Effort::Xhigh);
3180    }
3181
3182    #[test]
3183    fn first_pass_prompt_is_adversarial_and_injects_context() {
3184        let prompt = super::first_pass_prompt(
3185            &claim(),
3186            "THE_DIFF_BODY",
3187            "INVIOLABLE CONSTRAINTS: never fake tests",
3188        );
3189
3190        assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
3191        assert!(prompt.contains("default to REJECT"));
3192        assert!(prompt.contains("INVIOLABLE CONSTRAINTS: never fake tests"));
3193        assert!(prompt.contains("THE_DIFF_BODY"));
3194        // Class-generalized review: grep the class, not the instance.
3195        assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
3196        assert!(prompt.contains("\"severity\""));
3197        assert!(prompt.contains("\"recommendation\""));
3198        assert!(prompt.contains("\"memory_skill\""));
3199        assert!(prompt.contains("\"anti_pattern_skill\""));
3200    }
3201
3202    #[test]
3203    fn strict_second_pass_is_a_completeness_critic() {
3204        let job = review_job(true);
3205        let first_output = pass_json();
3206        let prompt = super::strict_second_pass_prompt(&job, &first_output);
3207
3208        assert!(prompt.contains("COMPLETENESS CRITIC"));
3209        assert!(prompt.contains("generalize"));
3210        // Inherits the class-sweep preamble.
3211        assert!(prompt.contains("GREP THE CLASS, NOT THE INSTANCE"));
3212    }
3213
3214    #[test]
3215    fn prompt_omits_context_block_when_empty() {
3216        let prompt = super::first_pass_prompt(&claim(), "d", "");
3217        // No dangling empty context header.
3218        assert!(!prompt.contains("INVIOLABLE CONSTRAINTS"));
3219        assert!(prompt.contains("PROVE THIS CLAIM FALSE"));
3220    }
3221
3222    fn sample_petition(attempts_so_far: u32) -> super::PetitionContext {
3223        super::PetitionContext {
3224            original_sha: "abc123".to_owned(),
3225            fix_sha: "def456".to_owned(),
3226            original_claim: "CLAIM: original | verified: cargo test | evidence: tests:cargo-test"
3227                .to_owned(),
3228            original_summary: "Original rejection summary.".to_owned(),
3229            original_findings: vec!["evidence too thin".to_owned()],
3230            original_structured_findings: vec![StructuredFinding {
3231                severity: FindingSeverity::High,
3232                title: "thin evidence".to_owned(),
3233                body: "Evidence pointer does not prove the claim.".to_owned(),
3234                file: "src/lib.rs".to_owned(),
3235                line_start: 1,
3236                line_end: 2,
3237                confidence: 90,
3238                recommendation: "Add executable evidence.".to_owned(),
3239            }],
3240            original_reviewer_model: "claude-opus-4-1".to_owned(),
3241            attempts_so_far,
3242        }
3243    }
3244
3245    #[test]
3246    fn petition_prompt_includes_original_findings_fix_sha_and_attempts() {
3247        let prompt = super::petition_prompt(&sample_petition(1), &claim(), "DIFF BODY", "");
3248
3249        assert!(prompt.contains("PETITION"));
3250        assert!(prompt.contains("original rejection SHA: abc123"));
3251        assert!(prompt.contains("fix commit SHA: def456"));
3252        assert!(prompt.contains("petition attempts so far: 1"));
3253        assert!(prompt.contains("Original rejection summary."));
3254        assert!(prompt.contains("thin evidence")); // structured finding title
3255        assert!(prompt.contains("DIFF BODY"));
3256        assert!(prompt.contains("\"verdict\": \"PASS\" | \"REJECT\" | \"FLAG\""));
3257    }
3258
3259    #[test]
3260    fn materialize_petition_prompt_rewrites_request_prompt() {
3261        let mut job = review_job(false);
3262        job.petition = Some(sample_petition(2));
3263        let original_prompt = job.request.prompt.clone();
3264        let updated = super::materialize_petition_prompt(job);
3265
3266        assert_ne!(updated.request.prompt, original_prompt);
3267        assert!(updated.request.prompt.contains("PETITION"));
3268        assert!(updated.request.prompt.contains("fix commit SHA: def456"));
3269        assert!(
3270            updated
3271                .request
3272                .prompt
3273                .contains("petition attempts so far: 2")
3274        );
3275    }
3276
3277    #[test]
3278    fn materialize_petition_prompt_passthrough_when_no_petition() {
3279        let job = review_job(false);
3280        let original_prompt = job.request.prompt.clone();
3281        let updated = super::materialize_petition_prompt(job);
3282        assert_eq!(updated.request.prompt, original_prompt);
3283    }
3284
3285    #[test]
3286    fn subprocess_runner_is_mockable() {
3287        struct MockRunner;
3288
3289        impl ProcessRunner for MockRunner {
3290            fn run(
3291                &self,
3292                invocation: &InvocationPlan,
3293                prompt: &str,
3294            ) -> Result<ProcessOutput, ReviewerError> {
3295                assert_eq!(invocation.program, "codex");
3296                assert_eq!(
3297                    invocation.args_for_prompt(prompt).last().unwrap(),
3298                    "review this"
3299                );
3300                Ok(ProcessOutput {
3301                    status_code: Some(0),
3302                    stdout: pass_json(),
3303                    stderr: String::new(),
3304                })
3305            }
3306        }
3307
3308        let request = ReviewRequest::new(
3309            Agent::Codex,
3310            "gpt-5.4",
3311            ReviewerHarness::Codex,
3312            "gpt-5.5",
3313            false,
3314            "review this",
3315        );
3316        let plan = ReviewPlan::build(request).unwrap();
3317        let output = plan.run_with("review this", &MockRunner).unwrap();
3318
3319        assert!(output.stdout.contains("PASS"));
3320    }
3321
3322    #[test]
3323    fn verdict_parser_extracts_rejection_findings() {
3324        let verdict = ParsedVerdict::parse(&reject_json("missing proof")).unwrap();
3325
3326        assert_eq!(verdict.verdict, Verdict::Reject);
3327        assert_eq!(verdict.structured_findings[0].title, "missing proof");
3328        assert_eq!(verdict.structured_findings[0].confidence, 95);
3329        assert!(verdict.findings[0].contains("missing proof"));
3330        assert_eq!(
3331            verdict.memory_skill_classification.learning_source,
3332            "missing proof"
3333        );
3334    }
3335
3336    fn flag_json(title: &str) -> String {
3337        serde_json::json!({
3338            "verdict": "FLAG",
3339            "summary": "Claim substantiated; flagging thin evidence.",
3340            "findings": [{
3341                "severity": "medium",
3342                "title": title,
3343                "body": "Evidence pointer is thin but the claim holds.",
3344                "file": "src/lib.rs",
3345                "line_start": 1,
3346                "line_end": 1,
3347                "confidence": 60,
3348                "recommendation": "Tighten evidence."
3349            }],
3350            "next_steps": ["Add stronger evidence."],
3351            "memory_skill": {
3352                "kind": "none",
3353                "learning_source": "",
3354                "reasoning": "No reusable procedural memory to propose."
3355            }
3356        })
3357        .to_string()
3358    }
3359
3360    #[test]
3361    fn verdict_parser_extracts_flag_findings() {
3362        let verdict = ParsedVerdict::parse(&flag_json("thin evidence")).unwrap();
3363
3364        assert_eq!(verdict.verdict, Verdict::Flag);
3365        assert_eq!(verdict.structured_findings[0].title, "thin evidence");
3366        assert_eq!(verdict.structured_findings[0].confidence, 60);
3367        assert!(verdict.findings[0].contains("thin evidence"));
3368    }
3369
3370    #[test]
3371    fn verdict_parser_rejects_flag_with_no_findings() {
3372        let output = serde_json::json!({
3373            "verdict": "FLAG",
3374            "summary": "Claim substantiated but flagging.",
3375            "findings": [],
3376            "next_steps": [],
3377            "memory_skill": {
3378                "kind": "none",
3379                "learning_source": "",
3380                "reasoning": "no reusable memory"
3381            }
3382        });
3383
3384        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3385
3386        assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
3387    }
3388
3389    #[test]
3390    fn verdict_parser_accepts_lowercase_flag_via_schema() {
3391        // Reviewer harnesses vary in case; serde rename_all = "UPPERCASE"
3392        // canonicalises, but reviewers may emit lowercase. Confirm the
3393        // existing parser does NOT silently coerce: lowercase should fail
3394        // (the schema is the source of truth for new entries).
3395        let output = serde_json::json!({
3396            "verdict": "flag",
3397            "summary": "Claim substantiated but flagging.",
3398            "findings": [{
3399                "severity": "low",
3400                "title": "x",
3401                "body": "y",
3402                "file": "src/lib.rs",
3403                "line_start": 1,
3404                "line_end": 1,
3405                "confidence": 50,
3406                "recommendation": "z"
3407            }],
3408            "next_steps": [],
3409            "memory_skill": {
3410                "kind": "none",
3411                "learning_source": "",
3412                "reasoning": "none"
3413            }
3414        });
3415
3416        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3417        assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3418    }
3419
3420    #[test]
3421    fn verdict_parser_rejects_missing_memory_skill_classification() {
3422        let output = serde_json::json!({
3423            "verdict": "PASS",
3424            "summary": "The claim is substantiated.",
3425            "findings": [],
3426            "next_steps": []
3427        });
3428
3429        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3430
3431        assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3432    }
3433
3434    #[test]
3435    fn verdict_parser_rejects_incompatible_memory_skill_classification() {
3436        let mut output: serde_json::Value = serde_json::from_str(&pass_json()).unwrap();
3437        output["memory_skill"]["kind"] = serde_json::json!("remediation_skill");
3438
3439        let error = ParsedVerdict::parse(&output.to_string()).unwrap_err();
3440
3441        assert!(matches!(error, ReviewerError::VerdictSchema { .. }));
3442    }
3443
3444    #[test]
3445    fn verdict_parser_accepts_normalized_float_confidence() {
3446        let mut output: serde_json::Value =
3447            serde_json::from_str(&reject_json("missing proof")).unwrap();
3448        output["findings"][0]["confidence"] = serde_json::json!(0.95);
3449
3450        let verdict = ParsedVerdict::parse(&output.to_string()).unwrap();
3451
3452        assert_eq!(verdict.structured_findings[0].confidence, 95);
3453    }
3454
3455    #[test]
3456    fn verdict_parser_rejects_legacy_line_protocol() {
3457        let error =
3458            ParsedVerdict::parse("VERDICT: REJECT\nFINDINGS:\n- missing proof\n").unwrap_err();
3459
3460        assert!(matches!(error, ReviewerError::VerdictJson { .. }));
3461    }
3462
3463    #[test]
3464    fn large_diff_materialization_falls_back_to_file_summary() {
3465        let files = "a.rs\nb.rs\nc.rs\n";
3466        let materialized = super::materialize_diff("branch:main", "tiny diff", files);
3467
3468        assert!(materialized.contains("too large to inline safely"));
3469        assert!(materialized.contains("actual_files=3"));
3470        assert!(materialized.contains("a.rs\nb.rs\nc.rs"));
3471        assert!(materialized.contains("inspect the repository directly"));
3472    }
3473
3474    #[test]
3475    fn review_queue_schedules_commits_without_running_models() {
3476        let temp = tempfile::tempdir().unwrap();
3477        let queue = ReviewQueue::new(temp.path());
3478
3479        queue.enqueue("abc123").unwrap();
3480
3481        let pending = queue.pending().unwrap();
3482        assert_eq!(pending.len(), 1);
3483        assert_eq!(pending[0].commit_sha, "abc123");
3484        assert!(!pending[0].run_id.is_empty());
3485
3486        let run = ReviewRunStore::new(temp.path())
3487            .read(&pending[0].run_id)
3488            .unwrap();
3489        assert_eq!(run.commit_sha, "abc123");
3490        assert_eq!(run.status, ReviewRunStatus::Queued);
3491        assert_eq!(run.entire_checkpoint, None);
3492    }
3493
3494    #[test]
3495    fn review_queue_summary_counts_pending_and_oldest() {
3496        let temp = tempfile::tempdir().unwrap();
3497        let queue = ReviewQueue::new(temp.path());
3498        std::fs::write(
3499            queue.path(),
3500            "{\"commit_sha\":\"abc123\",\"enqueued_at_unix\":30}\n{\"commit_sha\":\"def456\",\"enqueued_at_unix\":10}\n",
3501        )
3502        .unwrap();
3503
3504        let summary = queue.summary().unwrap();
3505
3506        assert_eq!(summary.pending_count, 2);
3507        assert_eq!(summary.oldest_enqueued_at_unix, Some(10));
3508        assert_eq!(summary.oldest_age_secs_at(42), Some(32));
3509    }
3510
3511    #[test]
3512    fn review_run_serializes_optional_entire_checkpoint() {
3513        let run = ReviewRun::queued_with_provenance(
3514            "run-id",
3515            "abcdef123456",
3516            "commit",
3517            Some(crate::provenance::EntireCheckpointRef {
3518                ref_name: "entire/session-abcdef".to_owned(),
3519                object_sha: "123456".to_owned(),
3520            }),
3521        );
3522
3523        let json = serde_json::to_string(&run).unwrap();
3524
3525        assert!(json.contains("entire_checkpoint"));
3526        assert!(json.contains("entire/session-abcdef"));
3527    }
3528
3529    #[test]
3530    fn review_run_omits_absent_optional_entire_checkpoint() {
3531        let run = ReviewRun::queued("run-id", "abcdef123456", "commit");
3532
3533        let json = serde_json::to_string(&run).unwrap();
3534
3535        assert!(!json.contains("entire_checkpoint"));
3536    }
3537
3538    #[test]
3539    fn review_run_status_counts_read_only_status_fields() {
3540        let temp = tempfile::tempdir().unwrap();
3541        let store = ReviewRunStore::new(temp.path());
3542        let queued = store.create_queued("abc123", "commit").unwrap();
3543        let mut failed = ReviewRun::queued("failed-run", "def456", "commit");
3544        failed.mark_failed("boom");
3545        store.write(&failed).unwrap();
3546        fs::write(store.path("malformed-run"), "{not-json\n").unwrap();
3547
3548        let counts = store.status_counts().unwrap();
3549
3550        assert_eq!(counts.queued, 1);
3551        assert_eq!(counts.failed, 1);
3552        assert_eq!(counts.running, 0);
3553        assert_eq!(counts.skipped_records, 1);
3554        assert_eq!(queued.status, ReviewRunStatus::Queued);
3555    }
3556
3557    #[test]
3558    fn review_cancel_marks_queued_run_and_removes_queue_item() {
3559        let temp = tempfile::tempdir().unwrap();
3560        let queue = ReviewQueue::new(temp.path());
3561        let queued = queue.enqueue("abc123").unwrap();
3562
3563        run_review_run_command(
3564            crate::cli::ReviewCommand::Cancel {
3565                run_id: queued.run_id.clone(),
3566                force: false,
3567            },
3568            temp.path(),
3569        )
3570        .unwrap();
3571
3572        assert!(queue.pending().unwrap().is_empty());
3573        let run = ReviewRunStore::new(temp.path())
3574            .read(&queued.run_id)
3575            .unwrap();
3576        assert_eq!(run.status, ReviewRunStatus::Cancelled);
3577    }
3578
3579    /// A pid guaranteed to be dead: spawn a trivial process, reap it, return its pid.
3580    fn reaped_pid() -> u32 {
3581        let mut child = Command::new("true").spawn().expect("spawn `true`");
3582        let pid = child.id();
3583        child.wait().expect("reap `true`");
3584        pid
3585    }
3586
3587    /// Persist a run pinned to `Running` with a specific worker pid, bypassing the
3588    /// normal lifecycle so liveness handling can be exercised deterministically.
3589    fn write_running_run(store: &ReviewRunStore, worker_pid: Option<u32>) -> ReviewRun {
3590        let mut run = store.create_queued("abc123", "commit").unwrap();
3591        run.status = ReviewRunStatus::Running;
3592        run.phase = "reviewing".to_owned();
3593        run.worker_pid = worker_pid;
3594        store.write(&run).unwrap();
3595        run
3596    }
3597
3598    #[test]
3599    fn pid_liveness_probe_tracks_real_processes() {
3600        assert!(pid_is_alive(std::process::id()));
3601        assert!(!pid_is_alive(reaped_pid()));
3602    }
3603
3604    #[test]
3605    fn reconcile_liveness_only_reaps_dead_running_runs() {
3606        let mut queued = ReviewRun::queued("id", "abc123", "commit");
3607        // A queued run is never touched, even if the predicate reports dead.
3608        assert!(!queued.reconcile_liveness(|_| false));
3609        assert_eq!(queued.status, ReviewRunStatus::Queued);
3610
3611        queued.mark_running("reviewing");
3612        // Running + alive stays running.
3613        assert!(!queued.reconcile_liveness(|_| true));
3614        assert_eq!(queued.status, ReviewRunStatus::Running);
3615        // Running + dead flips to failed with a stale-worker reason.
3616        assert!(queued.reconcile_liveness(|_| false));
3617        assert_eq!(queued.status, ReviewRunStatus::Failed);
3618        assert!(queued.error.as_deref().unwrap().contains("stale run"));
3619        assert!(queued.worker_pid.is_none());
3620
3621        // A legacy running run with no recorded pid is left alone.
3622        let mut legacy = ReviewRun::queued("id2", "def456", "commit");
3623        legacy.status = ReviewRunStatus::Running;
3624        legacy.worker_pid = None;
3625        assert!(!legacy.reconcile_liveness(|_| false));
3626        assert_eq!(legacy.status, ReviewRunStatus::Running);
3627    }
3628
3629    #[test]
3630    fn review_status_reaps_running_run_with_dead_worker_and_persists() {
3631        let temp = tempfile::tempdir().unwrap();
3632        let store = ReviewRunStore::new(temp.path());
3633        let run = write_running_run(&store, Some(reaped_pid()));
3634
3635        let reconciled = store.read_reconciled(&run.id).unwrap();
3636        assert_eq!(reconciled.status, ReviewRunStatus::Failed);
3637        assert!(reconciled.error.as_deref().unwrap().contains("stale run"));
3638
3639        // The correction is persisted, not just displayed.
3640        assert_eq!(store.read(&run.id).unwrap().status, ReviewRunStatus::Failed);
3641        // list_reconciled agrees.
3642        let listed = store.list_reconciled().unwrap();
3643        assert_eq!(listed.len(), 1);
3644        assert_eq!(listed[0].status, ReviewRunStatus::Failed);
3645    }
3646
3647    #[test]
3648    fn review_status_leaves_running_run_with_live_worker() {
3649        let temp = tempfile::tempdir().unwrap();
3650        let store = ReviewRunStore::new(temp.path());
3651        let run = write_running_run(&store, Some(std::process::id()));
3652
3653        let reconciled = store.read_reconciled(&run.id).unwrap();
3654        assert_eq!(reconciled.status, ReviewRunStatus::Running);
3655    }
3656
3657    #[test]
3658    fn cancel_reaps_running_run_with_dead_worker_without_force() {
3659        let temp = tempfile::tempdir().unwrap();
3660        let store = ReviewRunStore::new(temp.path());
3661        let run = write_running_run(&store, Some(reaped_pid()));
3662
3663        let cancelled = store.cancel(&run.id, false).unwrap();
3664        assert_eq!(cancelled.status, ReviewRunStatus::Failed);
3665        assert!(cancelled.error.as_deref().unwrap().contains("stale run"));
3666    }
3667
3668    #[test]
3669    fn cancel_refuses_live_running_run_without_force() {
3670        let temp = tempfile::tempdir().unwrap();
3671        let store = ReviewRunStore::new(temp.path());
3672        let run = write_running_run(&store, Some(std::process::id()));
3673
3674        let error = store.cancel(&run.id, false).unwrap_err();
3675        assert!(matches!(error, ReviewerError::ReviewRunStillAlive { .. }));
3676        // The run is untouched.
3677        assert_eq!(
3678            store.read(&run.id).unwrap().status,
3679            ReviewRunStatus::Running
3680        );
3681    }
3682
3683    #[test]
3684    fn cancel_force_kills_live_worker_and_cancels() {
3685        let temp = tempfile::tempdir().unwrap();
3686        let store = ReviewRunStore::new(temp.path());
3687        let mut child = Command::new("sleep")
3688            .arg("30")
3689            .spawn()
3690            .expect("spawn sleep");
3691        let pid = child.id();
3692        let run = write_running_run(&store, Some(pid));
3693
3694        let cancelled = store.cancel(&run.id, true).unwrap();
3695        assert_eq!(cancelled.status, ReviewRunStatus::Cancelled);
3696
3697        // Reap the killed child, then confirm it is truly gone.
3698        let _ = child.wait();
3699        assert!(!pid_is_alive(pid));
3700    }
3701
3702    #[test]
3703    fn cancel_legacy_running_run_requires_force_then_reaps() {
3704        let temp = tempfile::tempdir().unwrap();
3705        let store = ReviewRunStore::new(temp.path());
3706        let run = write_running_run(&store, None);
3707
3708        let error = store.cancel(&run.id, false).unwrap_err();
3709        assert!(matches!(
3710            error,
3711            ReviewerError::ReviewRunLivenessUnknown { .. }
3712        ));
3713
3714        let reaped = store.cancel(&run.id, true).unwrap();
3715        assert_eq!(reaped.status, ReviewRunStatus::Failed);
3716    }
3717
3718    #[test]
3719    fn cancel_refuses_already_terminal_run() {
3720        let temp = tempfile::tempdir().unwrap();
3721        let store = ReviewRunStore::new(temp.path());
3722        let run = store.create_queued("abc123", "commit").unwrap();
3723        store.mark_completed(&run.id, 0).unwrap();
3724
3725        let error = store.cancel(&run.id, true).unwrap_err();
3726        assert!(matches!(
3727            error,
3728            ReviewerError::CannotCancelReview {
3729                status: ReviewRunStatus::Completed,
3730                ..
3731            }
3732        ));
3733    }
3734
3735    #[test]
3736    fn execute_review_records_reject_verdict() {
3737        let temp = tempfile::tempdir().unwrap();
3738        let store = LedgerStore::new(temp.path());
3739        let job = review_job(false);
3740        let runner = SequenceRunner::new([reject_json("unsupported")]);
3741
3742        let execution = execute_review_job(job, &runner, &store).unwrap();
3743
3744        assert_eq!(execution.entries.len(), 1);
3745        assert_eq!(execution.entries[0].verdict, Verdict::Reject);
3746        assert_eq!(
3747            execution.entries[0].structured_findings[0].title,
3748            "unsupported"
3749        );
3750        assert!(
3751            execution.entries[0]
3752                .raw_reviewer_output
3753                .contains("\"REJECT\"")
3754        );
3755        assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3756    }
3757
3758    #[test]
3759    fn strict_two_pass_records_both_clean_passes() {
3760        let temp = tempfile::tempdir().unwrap();
3761        let store = LedgerStore::new(temp.path());
3762        let job = review_job(true);
3763        let runner = SequenceRunner::new([pass_json(), pass_json()]);
3764
3765        let execution = execute_review_job(job, &runner, &store).unwrap();
3766
3767        assert_eq!(execution.entries.len(), 2);
3768        assert_eq!(store.read_history().unwrap().len(), 2);
3769        assert_eq!(execution.entries[0].reviewer.model, "gpt-5.5");
3770        assert_eq!(execution.entries[1].reviewer.model, "claude-opus-4-8");
3771    }
3772
3773    #[test]
3774    fn strict_arbiter_model_must_be_third_model() {
3775        let temp = tempfile::tempdir().unwrap();
3776        let store = LedgerStore::new(temp.path());
3777        let mut job = review_job(true);
3778        job.strict.as_mut().unwrap().arbiter_model = "gpt-5.5".to_owned();
3779        let runner = SequenceRunner::new([pass_json()]);
3780
3781        let error = execute_review_job(job, &runner, &store).unwrap_err();
3782
3783        assert!(matches!(
3784            error,
3785            ReviewerError::StrictArbiterModelNotDistinct
3786        ));
3787    }
3788
3789    #[test]
3790    fn strict_goal_policy_stops_at_configured_lie_or_fuckup_count() {
3791        let policy = StrictGoalPolicy {
3792            stop_after_lies: 2,
3793            stop_after_fuckups: 3,
3794        };
3795
3796        assert_eq!(
3797            policy.decide(StrictGoalCounters {
3798                lies_exposed: 1,
3799                fuckups_registered: 2
3800            }),
3801            StrictGoalDecision::Continue
3802        );
3803        assert_eq!(
3804            policy.decide(StrictGoalCounters {
3805                lies_exposed: 2,
3806                fuckups_registered: 0
3807            }),
3808            StrictGoalDecision::Stop {
3809                reason: StrictGoalStopReason::LiesExposed
3810            }
3811        );
3812        assert_eq!(
3813            policy.decide(StrictGoalCounters {
3814                lies_exposed: 0,
3815                fuckups_registered: 3
3816            }),
3817            StrictGoalDecision::Stop {
3818                reason: StrictGoalStopReason::FuckupsRegistered
3819            }
3820        );
3821    }
3822
3823    #[test]
3824    fn drain_once_reviews_each_commit_once_and_clears_queue() {
3825        let temp = tempfile::tempdir().unwrap();
3826        let store = LedgerStore::new(temp.path());
3827        let queue = ReviewQueue::new(temp.path());
3828        queue.enqueue("abc123").unwrap();
3829        queue.enqueue("abc123").unwrap(); // duplicate SHA reviewed only once
3830        queue.enqueue("def456").unwrap();
3831
3832        let loader = StaticLoader::new();
3833        let runner = SequenceRunner::new([reject_json("unsupported"), pass_json()]);
3834        let selection = selection();
3835
3836        let report = drain_once(
3837            &queue,
3838            &loader,
3839            &selection,
3840            "",
3841            &runner,
3842            &store,
3843            &TruthMirrorConfig::default(),
3844        )
3845        .unwrap();
3846
3847        assert_eq!(report.reviewed, ["abc123", "def456"]);
3848        assert_eq!(report.ledger_entries, 2);
3849        assert!(queue.pending().unwrap().is_empty());
3850        assert_eq!(store.read_history().unwrap().len(), 2);
3851        assert_eq!(store.unresolved_rejections().unwrap().len(), 1);
3852
3853        let runs = ReviewRunStore::new(temp.path()).list().unwrap();
3854        assert_eq!(runs.len(), 3);
3855        assert_eq!(
3856            runs.iter()
3857                .filter(|run| run.status == ReviewRunStatus::Completed)
3858                .count(),
3859            2
3860        );
3861        assert_eq!(
3862            runs.iter()
3863                .filter(|run| run.status == ReviewRunStatus::Cancelled)
3864                .count(),
3865            1
3866        );
3867    }
3868
3869    #[test]
3870    fn drain_once_completes_when_memory_skill_extraction_fails() {
3871        let temp = tempfile::tempdir().unwrap();
3872        let store = LedgerStore::new(temp.path());
3873        let queue = ReviewQueue::new(temp.path());
3874        let sha = "a".repeat(40);
3875        queue.enqueue(&sha).unwrap();
3876        let loader = StaticLoader::new();
3877        let runner = ConstRunner::new(pass_json());
3878        let mut config = TruthMirrorConfig::default();
3879        config.skills.enabled = true;
3880        config.memory_skill.enabled = true;
3881        config.memory_skill.signals.min_occurrences = 1;
3882        config.memory_skill.scan.blocked_patterns = vec!["Capture a verified procedure".to_owned()];
3883
3884        let report =
3885            drain_once(&queue, &loader, &selection(), "", &runner, &store, &config).unwrap();
3886
3887        assert_eq!(report.reviewed, [sha]);
3888        assert_eq!(report.ledger_entries, 1);
3889        assert!(queue.pending().unwrap().is_empty());
3890        assert_eq!(store.read_history().unwrap().len(), 1);
3891        let candidate_dir =
3892            crate::memory_skill::MemorySkillStore::new(temp.path(), &config.memory_skill)
3893                .unwrap()
3894                .candidate_dir()
3895                .to_path_buf();
3896        assert!(!candidate_dir.exists());
3897    }
3898
3899    #[test]
3900    fn drain_once_is_a_noop_on_empty_queue() {
3901        let temp = tempfile::tempdir().unwrap();
3902        let store = LedgerStore::new(temp.path());
3903        let queue = ReviewQueue::new(temp.path());
3904        let loader = StaticLoader::new();
3905        let runner = ConstRunner::new(pass_json());
3906
3907        let report = drain_once(
3908            &queue,
3909            &loader,
3910            &selection(),
3911            "",
3912            &runner,
3913            &store,
3914            &TruthMirrorConfig::default(),
3915        )
3916        .unwrap();
3917
3918        assert!(report.reviewed.is_empty());
3919        assert_eq!(report.ledger_entries, 0);
3920        assert_eq!(store.read_history().unwrap().len(), 0);
3921    }
3922
3923    #[test]
3924    fn until_empty_keeps_watching_while_queue_has_items() {
3925        // A non-empty queue is reported as `None` elapsed → keep watching.
3926        assert_eq!(
3927            until_empty_decision(None, 60),
3928            UntilEmptyDecision::KeepWatching
3929        );
3930    }
3931
3932    #[test]
3933    fn until_empty_keeps_watching_inside_grace_window() {
3934        // Empty for less than the grace window → keep watching for late arrivals.
3935        assert_eq!(
3936            until_empty_decision(Some(0), 60),
3937            UntilEmptyDecision::KeepWatching
3938        );
3939        assert_eq!(
3940            until_empty_decision(Some(59), 60),
3941            UntilEmptyDecision::KeepWatching
3942        );
3943    }
3944
3945    #[test]
3946    fn until_empty_exits_once_grace_window_elapses() {
3947        // Empty through a full grace window (>= grace) → exit.
3948        assert_eq!(until_empty_decision(Some(60), 60), UntilEmptyDecision::Exit);
3949        assert_eq!(
3950            until_empty_decision(Some(120), 60),
3951            UntilEmptyDecision::Exit
3952        );
3953    }
3954
3955    #[test]
3956    fn until_empty_with_zero_grace_exits_immediately_when_empty() {
3957        assert_eq!(until_empty_decision(Some(0), 0), UntilEmptyDecision::Exit);
3958        // Still keeps watching while items remain, even with zero grace.
3959        assert_eq!(
3960            until_empty_decision(None, 0),
3961            UntilEmptyDecision::KeepWatching
3962        );
3963    }
3964
3965    #[test]
3966    fn strict_goal_loop_stops_at_configured_lie_count() {
3967        let temp = tempfile::tempdir().unwrap();
3968        let store = LedgerStore::new(temp.path());
3969        let policy = StrictGoalPolicy {
3970            stop_after_lies: 1,
3971            stop_after_fuckups: 0,
3972        };
3973        let runner = SequenceRunner::new([reject_json("lie")]);
3974
3975        let outcome = run_strict_goal_loop(
3976            "abc123",
3977            &claim(),
3978            "diff",
3979            "",
3980            &selection(),
3981            policy,
3982            5,
3983            &runner,
3984            &store,
3985        )
3986        .unwrap();
3987
3988        assert_eq!(outcome.passes, 1);
3989        assert_eq!(outcome.counters.lies_exposed, 1);
3990        assert_eq!(outcome.stop_reason, Some(StrictGoalStopReason::LiesExposed));
3991        assert_eq!(store.read_history().unwrap().len(), 1);
3992    }
3993
3994    #[test]
3995    fn strict_goal_loop_terminates_at_max_passes_for_honest_agent() {
3996        let temp = tempfile::tempdir().unwrap();
3997        let store = LedgerStore::new(temp.path());
3998        let policy = StrictGoalPolicy {
3999            stop_after_lies: 2,
4000            stop_after_fuckups: 5,
4001        };
4002        let runner = ConstRunner::new(pass_json());
4003
4004        let outcome = run_strict_goal_loop(
4005            "abc123",
4006            &claim(),
4007            "diff",
4008            "",
4009            &selection(),
4010            policy,
4011            3,
4012            &runner,
4013            &store,
4014        )
4015        .unwrap();
4016
4017        assert_eq!(outcome.passes, 3);
4018        assert_eq!(outcome.counters.lies_exposed, 0);
4019        assert_eq!(outcome.stop_reason, None);
4020        assert_eq!(store.read_history().unwrap().len(), 3);
4021    }
4022
4023    #[test]
4024    fn strict_goal_loop_stops_when_fuckups_accumulate() {
4025        let temp = tempfile::tempdir().unwrap();
4026        let store = LedgerStore::new(temp.path());
4027        let policy = StrictGoalPolicy {
4028            stop_after_lies: 0,
4029            stop_after_fuckups: 2,
4030        };
4031        // Each structured finding registers one fuckup; two passes hit N=2.
4032        let runner = ConstRunner::new(reject_json("nit"));
4033
4034        let outcome = run_strict_goal_loop(
4035            "abc123",
4036            &claim(),
4037            "diff",
4038            "",
4039            &selection(),
4040            policy,
4041            10,
4042            &runner,
4043            &store,
4044        )
4045        .unwrap();
4046
4047        assert_eq!(outcome.passes, 2);
4048        assert_eq!(outcome.counters.lies_exposed, 2);
4049        assert_eq!(outcome.counters.fuckups_registered, 2);
4050        assert_eq!(
4051            outcome.stop_reason,
4052            Some(StrictGoalStopReason::FuckupsRegistered)
4053        );
4054    }
4055
4056    proptest! {
4057        #[test]
4058        fn strict_goal_loop_never_exceeds_max_passes(max in 1u32..6) {
4059            let temp = tempfile::tempdir().unwrap();
4060            let store = LedgerStore::new(temp.path());
4061            // Both thresholds disabled: only the ceiling can stop the loop.
4062            let policy = StrictGoalPolicy { stop_after_lies: 0, stop_after_fuckups: 0 };
4063            let runner = ConstRunner::new(pass_json());
4064
4065            let outcome = run_strict_goal_loop(
4066                "abc123", &claim(), "diff", "", &selection(), policy, max, &runner, &store,
4067            )
4068            .unwrap();
4069
4070            prop_assert!(outcome.passes <= max);
4071            prop_assert_eq!(outcome.passes, max);
4072            prop_assert!(outcome.stop_reason.is_none());
4073        }
4074    }
4075
4076    proptest! {
4077        #[test]
4078        fn model_opposition_is_enforced_for_arbitrary_models(
4079            watched in "[A-Za-z0-9._/-]{1,32}",
4080            reviewer in "[A-Za-z0-9._/-]{1,32}",
4081        ) {
4082            let request = ReviewRequest::new(
4083                Agent::Codex,
4084                watched.clone(),
4085                ReviewerHarness::Codex,
4086                reviewer.clone(),
4087                false,
4088                "review this",
4089            );
4090            let result = ReviewPlan::build(request);
4091
4092            // Use the same normalisation that ReviewPlan::build uses so that
4093            // provider-prefixed models (e.g. "openai-codex/gpt-5.5" vs "gpt-5.5")
4094            // are treated as the same model after the B2 fix.
4095            if normalized_model(watched.trim()) == normalized_model(reviewer.trim()) {
4096                let blocked = matches!(result, Err(ReviewerError::SameModelWithoutWaiver { .. }));
4097                prop_assert!(blocked);
4098            } else {
4099                prop_assert!(result.is_ok());
4100            }
4101        }
4102    }
4103
4104    fn claim() -> Claim {
4105        Claim::new(
4106            "add review",
4107            "cargo test",
4108            vec![EvidenceRef::parse("tests:cargo-test").unwrap()],
4109        )
4110        .unwrap()
4111    }
4112
4113    fn selection() -> ReviewSelection {
4114        ReviewSelection {
4115            watched_agent: Agent::Codex,
4116            watched_model: "gpt-5.4".to_owned(),
4117            reviewer_harness: ReviewerHarness::Codex,
4118            reviewer_model: "gpt-5.5".to_owned(),
4119            reviewer_effort: Effort::Xhigh,
4120            allow_same_model: false,
4121            strict: None,
4122        }
4123    }
4124
4125    struct StaticLoader {
4126        claim: Claim,
4127        diff: String,
4128    }
4129
4130    impl StaticLoader {
4131        fn new() -> Self {
4132            Self {
4133                claim: claim(),
4134                diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
4135            }
4136        }
4137    }
4138
4139    impl MaterialLoader for StaticLoader {
4140        fn load(&self, _sha: &str) -> Result<(Claim, String), ReviewerError> {
4141            Ok((self.claim.clone(), self.diff.clone()))
4142        }
4143    }
4144
4145    struct ConstRunner {
4146        output: String,
4147    }
4148
4149    impl ConstRunner {
4150        fn new(output: impl Into<String>) -> Self {
4151            Self {
4152                output: output.into(),
4153            }
4154        }
4155    }
4156
4157    impl ProcessRunner for ConstRunner {
4158        fn run(
4159            &self,
4160            _invocation: &InvocationPlan,
4161            _prompt: &str,
4162        ) -> Result<ProcessOutput, ReviewerError> {
4163            Ok(ProcessOutput {
4164                status_code: Some(0),
4165                stdout: self.output.clone(),
4166                stderr: String::new(),
4167            })
4168        }
4169    }
4170
4171    fn review_job(strict: bool) -> ReviewJob {
4172        let claim = claim();
4173        ReviewJob {
4174            commit_sha: "abc123".to_owned(),
4175            diff: "diff --git a/src/lib.rs b/src/lib.rs".to_owned(),
4176            context: String::new(),
4177            request: ReviewRequest::new(
4178                Agent::Codex,
4179                "gpt-5.4",
4180                ReviewerHarness::Codex,
4181                "gpt-5.5",
4182                false,
4183                "review this",
4184            ),
4185            claim,
4186            strict: strict.then_some(StrictReviewConfig {
4187                arbiter_harness: ReviewerHarness::Claude,
4188                arbiter_model: "claude-opus-4-8".to_owned(),
4189                arbiter_effort: Effort::Xhigh,
4190            }),
4191            petition: None,
4192        }
4193    }
4194
4195    struct SequenceRunner {
4196        outputs: RefCell<VecDeque<String>>,
4197    }
4198
4199    impl SequenceRunner {
4200        fn new<I, S>(outputs: I) -> Self
4201        where
4202            I: IntoIterator<Item = S>,
4203            S: Into<String>,
4204        {
4205            Self {
4206                outputs: RefCell::new(outputs.into_iter().map(Into::into).collect()),
4207            }
4208        }
4209    }
4210
4211    impl ProcessRunner for SequenceRunner {
4212        fn run(
4213            &self,
4214            _invocation: &InvocationPlan,
4215            _prompt: &str,
4216        ) -> Result<ProcessOutput, ReviewerError> {
4217            let stdout = self.outputs.borrow_mut().pop_front().unwrap();
4218            Ok(ProcessOutput {
4219                status_code: Some(0),
4220                stdout,
4221                stderr: String::new(),
4222            })
4223        }
4224    }
4225
4226    #[test]
4227    fn extract_verdict_json_handles_raw_fenced_and_prose_wrapped_output() {
4228        // Raw JSON (the documented contract) passes through untouched.
4229        let raw = r#"{"verdict":"PASS"}"#;
4230        assert_eq!(super::extract_verdict_json(raw), raw);
4231
4232        // Prose around a ```json fence: the fenced body is extracted.
4233        let fenced = "Here is my verdict:\n```json\n{\"verdict\":\"PASS\"}\n```\nthanks";
4234        assert_eq!(
4235            super::extract_verdict_json(fenced),
4236            "{\"verdict\":\"PASS\"}"
4237        );
4238
4239        // Bare ``` fence without an info string.
4240        let bare = "verdict below\n```\n{\"a\":1}\n```";
4241        assert_eq!(super::extract_verdict_json(bare), "{\"a\":1}");
4242
4243        // A non-JSON fence first, the JSON fence second: skips to the JSON one.
4244        let two = "```\nnot json\n```\nthen\n```json\n{\"b\":2}\n```";
4245        assert_eq!(super::extract_verdict_json(two), "{\"b\":2}");
4246
4247        // No fence at all: the outermost brace span.
4248        let prose = "The verdict is {\"a\": {\"b\":2}} as required.";
4249        assert_eq!(super::extract_verdict_json(prose), "{\"a\": {\"b\":2}}");
4250
4251        // Nothing JSON-shaped: falls back to the trimmed output so serde's
4252        // error carries the real text.
4253        assert_eq!(super::extract_verdict_json("  nope  "), "nope");
4254    }
4255
4256    #[test]
4257    fn parsed_verdict_accepts_markdown_fenced_reviewer_output() {
4258        // The dead-watcher regression: reviewer models wrap the verdict in
4259        // prose + a fenced block; 0.8.0 demanded JSON at byte 0 and the first
4260        // real review errored out, so the queue never drained.
4261        let fenced = format!(
4262            "The fix looks good. Verdict follows.\n\n```json\n{}\n```\n",
4263            pass_json()
4264        );
4265        let parsed = ParsedVerdict::parse(&fenced).unwrap();
4266        assert_eq!(parsed.verdict, Verdict::Pass);
4267        // `raw` preserves the reviewer's original output verbatim.
4268        assert_eq!(parsed.raw, fenced);
4269    }
4270
4271    #[test]
4272    fn queued_review_lines_without_petition_field_still_parse() {
4273        let legacy = r#"{"run_id":"r1","commit_sha":"abc","enqueued_at_unix":1}"#;
4274        let item: super::QueuedReview = serde_json::from_str(legacy).unwrap();
4275        assert_eq!(item.petition_for, None);
4276    }
4277
4278    #[test]
4279    fn enqueue_petition_round_trips_petition_for() {
4280        let temp = tempfile::tempdir().unwrap();
4281        let queue = ReviewQueue::new(temp.path());
4282        queue.enqueue("normal1").unwrap();
4283        queue.enqueue_petition("fix456", "orig123").unwrap();
4284
4285        let pending = queue.pending().unwrap();
4286        assert_eq!(pending.len(), 2);
4287        assert_eq!(pending[0].petition_for, None);
4288        assert_eq!(pending[1].commit_sha, "fix456");
4289        assert_eq!(pending[1].petition_for.as_deref(), Some("orig123"));
4290    }
4291
4292    #[test]
4293    fn drain_once_runs_petition_jobs_and_transitions_the_original_rejection() {
4294        use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4295
4296        let temp = tempfile::tempdir().unwrap();
4297        let store = LedgerStore::new(temp.path());
4298        let queue = ReviewQueue::new(temp.path());
4299
4300        // The original rejection, plus the CLI's attempt bookkeeping
4301        // (`resolve --fixed-by` counts the attempt when it enqueues).
4302        let rejected = LedgerEntry::new(
4303            "orig123",
4304            Verdict::Reject,
4305            "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4306            vec!["tests:cargo-test".to_owned()],
4307            ReviewerConfig::new("claude", "claude-opus-4-1", false),
4308            vec!["evidence too thin".to_owned()],
4309        );
4310        store.append_entry(&rejected).unwrap();
4311        store
4312            .append_petition_transition(
4313                "orig123",
4314                Disposition::Open,
4315                ResolutionKind::Resolved,
4316                "petition review enqueued: fix=fix456, attempts=1/2",
4317                1,
4318            )
4319            .unwrap();
4320        queue.enqueue_petition("fix456", "orig123").unwrap();
4321
4322        let loader = StaticLoader::new();
4323        let runner = ConstRunner::new(pass_json());
4324        let report = drain_once(
4325            &queue,
4326            &loader,
4327            &selection(),
4328            "",
4329            &runner,
4330            &store,
4331            &TruthMirrorConfig::default(),
4332        )
4333        .unwrap();
4334
4335        assert_eq!(report.reviewed, ["fix456"]);
4336        // The petition review entry lands under the FIX sha, tagged with the
4337        // original, carrying the CLI's counter unchanged (no double increment).
4338        let review = store.show("fix456").unwrap();
4339        assert_eq!(review.petition_for.as_deref(), Some("orig123"));
4340        assert_eq!(review.petition_attempts, 1);
4341        // apply_petition_transition resolved the original rejection.
4342        let original = store.show("orig123").unwrap();
4343        assert_eq!(original.disposition, Disposition::Resolved);
4344        assert!(queue.pending().unwrap().is_empty());
4345        assert!(store.blocking_rejections().unwrap().is_empty());
4346    }
4347
4348    #[test]
4349    fn extract_verdict_json_takes_leading_json_before_trailing_prose() {
4350        // Round-2 review finding: leading JSON + trailing prose used to be
4351        // returned whole (starts_with '{'), and serde died on the trailer.
4352        let leading = "{\"verdict\":\"PASS\"} \n\nHope this helps!";
4353        assert_eq!(
4354            super::extract_verdict_json(leading),
4355            "{\"verdict\":\"PASS\"}"
4356        );
4357
4358        // Prose on both sides without fences: first complete JSON value wins
4359        // even when the trailing prose contains a stray closing brace.
4360        let both = "verdict: {\"a\":1} trailing prose with a stray } brace";
4361        assert_eq!(super::extract_verdict_json(both), "{\"a\":1}");
4362    }
4363
4364    #[test]
4365    fn drain_once_reviews_normal_and_petition_items_for_the_same_fix_sha() {
4366        use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4367
4368        let temp = tempfile::tempdir().unwrap();
4369        let store = LedgerStore::new(temp.path());
4370        let queue = ReviewQueue::new(temp.path());
4371
4372        let rejected = LedgerEntry::new(
4373            "orig123",
4374            Verdict::Reject,
4375            "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4376            vec!["tests:cargo-test".to_owned()],
4377            ReviewerConfig::new("claude", "claude-opus-4-1", false),
4378            vec!["evidence too thin".to_owned()],
4379        );
4380        store.append_entry(&rejected).unwrap();
4381        store
4382            .append_petition_transition(
4383                "orig123",
4384                Disposition::Open,
4385                ResolutionKind::Resolved,
4386                "petition review enqueued: fix=fix456, attempts=1/2",
4387                1,
4388            )
4389            .unwrap();
4390
4391        // The SAME fix commit is queued twice with different identities: its
4392        // own post-commit review AND a petition review. Dedup by bare SHA
4393        // used to cancel one of them silently.
4394        queue.enqueue("fix456").unwrap();
4395        queue.enqueue_petition("fix456", "orig123").unwrap();
4396
4397        let loader = StaticLoader::new();
4398        let runner = ConstRunner::new(pass_json());
4399        let report = drain_once(
4400            &queue,
4401            &loader,
4402            &selection(),
4403            "",
4404            &runner,
4405            &store,
4406            &TruthMirrorConfig::default(),
4407        )
4408        .unwrap();
4409
4410        assert_eq!(report.reviewed, ["fix456", "fix456"]);
4411        assert!(queue.pending().unwrap().is_empty());
4412        // Both entries exist in history: the fix commit's own PASS and the
4413        // petition review tagged with the original rejection.
4414        let history = store.read_history().unwrap();
4415        let fix_entries: Vec<_> = history
4416            .iter()
4417            .filter(|entry| entry.commit_sha == "fix456")
4418            .collect();
4419        assert_eq!(fix_entries.len(), 2);
4420        assert!(fix_entries.iter().any(|entry| entry.petition_for.is_none()));
4421        assert!(
4422            fix_entries
4423                .iter()
4424                .any(|entry| entry.petition_for.as_deref() == Some("orig123"))
4425        );
4426        // And the original rejection resolved via the petition leg.
4427        assert_eq!(
4428            store.show("orig123").unwrap().disposition,
4429            crate::ledger::Disposition::Resolved
4430        );
4431    }
4432
4433    #[test]
4434    fn flag_petition_verdict_resolves_the_original_rejection() {
4435        use crate::ledger::{Disposition, LedgerEntry, ResolutionKind, ReviewerConfig};
4436
4437        let temp = tempfile::tempdir().unwrap();
4438        let store = LedgerStore::new(temp.path());
4439        let queue = ReviewQueue::new(temp.path());
4440        let rejected = LedgerEntry::new(
4441            "orig123",
4442            Verdict::Reject,
4443            "CLAIM: original | verified: cargo test | evidence: tests:cargo-test",
4444            vec!["tests:cargo-test".to_owned()],
4445            ReviewerConfig::new("claude", "claude-opus-4-1", false),
4446            vec!["evidence too thin".to_owned()],
4447        );
4448        store.append_entry(&rejected).unwrap();
4449        store
4450            .append_petition_transition(
4451                "orig123",
4452                Disposition::Open,
4453                ResolutionKind::Resolved,
4454                "petition review enqueued: fix=fix456, attempts=1/2",
4455                1,
4456            )
4457            .unwrap();
4458        queue.enqueue_petition("fix456", "orig123").unwrap();
4459
4460        // FLAG = the fix materially addresses the findings + non-blocking
4461        // debt (per the petition prompt) — it must ACCEPT, with the debt
4462        // recorded on the petition review entry itself.
4463        let loader = StaticLoader::new();
4464        let runner = ConstRunner::new(flag_json("evidence could be tighter"));
4465        drain_once(
4466            &queue,
4467            &loader,
4468            &selection(),
4469            "",
4470            &runner,
4471            &store,
4472            &TruthMirrorConfig::default(),
4473        )
4474        .unwrap();
4475
4476        assert_eq!(
4477            store.show("orig123").unwrap().disposition,
4478            crate::ledger::Disposition::Resolved
4479        );
4480        let history = store.read_history().unwrap();
4481        let petition_entry = history
4482            .iter()
4483            .find(|entry| {
4484                entry.petition_for.as_deref() == Some("orig123") && entry.commit_sha == "fix456"
4485            })
4486            .unwrap();
4487        assert_eq!(petition_entry.verdict, Verdict::Flag);
4488    }
4489}