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