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