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