1use 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#[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 #[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 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 allow_same_model: allow_same_model || config.allow_same_model,
150 strict: None,
151 })
152 }
153
154 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 if request.watched_model.trim().is_empty() {
233 if !request.allow_same_model {
234 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 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 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 "--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 pub context: String,
437 pub request: ReviewRequest,
438 pub strict: Option<StrictReviewConfig>,
439 pub petition: Option<PetitionContext>,
446}
447
448#[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
473fn 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 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 if let Some(last) = entries.last() {
530 apply_petition_transition(&job, last, store)?;
531 }
532
533 Ok(ReviewExecution { entries })
534}
535
536fn 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 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 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
595fn 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 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
674fn 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 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 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 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 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 #[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 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
1013fn stale_worker_reason(pid: u32) -> String {
1015 format!("worker process {pid} exited without recording a verdict (stale run)")
1016}
1017
1018fn 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 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 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 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 #[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 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 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 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
1461pub trait MaterialLoader {
1464 fn load(&self, sha: &str) -> Result<(Claim, String), ReviewerError>;
1465}
1466
1467#[derive(Clone, Debug, Default)]
1468pub struct GitMaterialLoader {
1469 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 pub failed: Vec<String>,
1502 pub ledger_entries: usize,
1503}
1504
1505pub 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 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 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 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
1689fn 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 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#[derive(Clone, Copy, Debug, Eq, PartialEq)]
1777pub enum UntilEmptyDecision {
1778 KeepWatching,
1781 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#[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 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 empty_since = None;
1909 }
1910
1911 std::thread::sleep(interval);
1912 }
1913 })();
1914
1915 let _ = crate::watcher::release_lock_if_owned(state_dir);
1918 outcome?;
1919 Ok(ExitCode::SUCCESS)
1920}
1921
1922pub 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 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#[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 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 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
2920fn 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 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 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, &config,
3223 )
3224 .unwrap();
3225
3226 assert!(selection.allow_same_model);
3227 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 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 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 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")); 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 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 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 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 assert!(!queued.reconcile_liveness(|_| false));
3677 assert_eq!(queued.status, ReviewRunStatus::Queued);
3678
3679 queued.mark_running("reviewing");
3680 assert!(!queued.reconcile_liveness(|_| true));
3682 assert_eq!(queued.status, ReviewRunStatus::Running);
3683 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 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 assert_eq!(store.read(&run.id).unwrap().status, ReviewRunStatus::Failed);
3709 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 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 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(); 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 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 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 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 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 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 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 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 let raw = r#"{"verdict":"PASS"}"#;
4432 assert_eq!(super::extract_verdict_json(raw), raw);
4433
4434 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 let bare = "verdict below\n```\n{\"a\":1}\n```";
4443 assert_eq!(super::extract_verdict_json(bare), "{\"a\":1}");
4444
4445 let two = "```\nnot json\n```\nthen\n```json\n{\"b\":2}\n```";
4447 assert_eq!(super::extract_verdict_json(two), "{\"b\":2}");
4448
4449 let prose = "The verdict is {\"a\": {\"b\":2}} as required.";
4451 assert_eq!(super::extract_verdict_json(prose), "{\"a\": {\"b\":2}}");
4452
4453 assert_eq!(super::extract_verdict_json(" nope "), "nope");
4456 }
4457
4458 #[test]
4459 fn parsed_verdict_accepts_markdown_fenced_reviewer_output() {
4460 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 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 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 let review = store.show("fix456").unwrap();
4541 assert_eq!(review.petition_for.as_deref(), Some("orig123"));
4542 assert_eq!(review.petition_attempts, 1);
4543 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 let leading = "{\"verdict\":\"PASS\"} \n\nHope this helps!";
4555 assert_eq!(
4556 super::extract_verdict_json(leading),
4557 "{\"verdict\":\"PASS\"}"
4558 );
4559
4560 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 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 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 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 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}