1use std::{
5 collections::BTreeMap,
6 fs, io,
7 path::{Path, PathBuf},
8};
9
10use serde::{Deserialize, Serialize};
11use thiserror::Error;
12
13#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
15#[serde(rename_all = "lowercase")]
16#[value(rename_all = "lowercase")]
17pub enum Effort {
18 Minimal,
19 Low,
20 Medium,
21 High,
22 #[default]
23 Xhigh,
24}
25
26impl Effort {
27 pub fn as_str(self) -> &'static str {
28 match self {
29 Effort::Minimal => "minimal",
30 Effort::Low => "low",
31 Effort::Medium => "medium",
32 Effort::High => "high",
33 Effort::Xhigh => "xhigh",
34 }
35 }
36
37 pub fn highest() -> Self {
39 Effort::Xhigh
40 }
41
42 pub fn claude_value(self) -> &'static str {
46 match self {
47 Effort::Minimal => "low",
48 other => other.as_str(),
49 }
50 }
51}
52
53#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
55pub struct HarnessSelection {
56 pub harness: String,
57 pub model: String,
58 #[serde(default)]
59 pub effort: Effort,
60}
61
62impl HarnessSelection {
63 pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
64 Self {
65 harness: harness.into(),
66 model: model.into(),
67 effort,
68 }
69 }
70}
71
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct AdversarialPair {
75 pub reviewer: HarnessSelection,
76 #[serde(default)]
77 pub arbiter: Option<HarnessSelection>,
78}
79
80#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
81pub struct TruthMirrorConfig {
82 #[serde(default = "default_ledger_dir")]
83 pub ledger_dir: String,
84 #[serde(default)]
85 pub allow_same_model: bool,
86 #[serde(default = "default_writer")]
88 pub default_writer: String,
89 #[serde(default)]
92 pub pairs: BTreeMap<String, AdversarialPair>,
93 #[serde(default)]
94 pub strict: StrictConfig,
95 #[serde(default)]
96 pub gates: GatesConfig,
97 #[serde(default)]
98 pub ground_truth: GroundTruthConfig,
99 #[serde(default)]
100 pub history: HistoryConfig,
101 #[serde(default)]
102 pub enforcement: EnforcementConfig,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub review: Option<LegacyReview>,
106}
107
108impl Default for TruthMirrorConfig {
109 fn default() -> Self {
110 Self {
111 ledger_dir: default_ledger_dir(),
112 allow_same_model: false,
113 default_writer: default_writer(),
114 pairs: default_pairs(),
115 strict: StrictConfig::default(),
116 gates: GatesConfig::default(),
117 ground_truth: GroundTruthConfig::default(),
118 history: HistoryConfig::default(),
119 enforcement: EnforcementConfig::default(),
120 review: None,
121 }
122 }
123}
124
125impl TruthMirrorConfig {
126 pub fn load_for_cli(
127 explicit_path: Option<&Path>,
128 state_dir: &Path,
129 ) -> Result<Self, ConfigError> {
130 let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
131 Self::load_or_default(path)
132 }
133
134 pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
135 let path = path.into();
136 match fs::read_to_string(&path) {
137 Ok(contents) => Self::from_toml_str(&path, &contents),
138 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
139 Err(source) => Err(ConfigError::Read { path, source }),
140 }
141 }
142
143 pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
144 let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
145 path: path.to_path_buf(),
146 source,
147 })?;
148 config.normalize();
149 config.validate()?;
150 Ok(config)
151 }
152
153 fn normalize(&mut self) {
157 let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
161 .into_iter()
162 .map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
163 .collect();
164 self.pairs = lowered;
165
166 let had_explicit_pairs = !self.pairs.is_empty();
167 let review = self.review.take();
168
169 if !had_explicit_pairs && review.is_some() {
170 self.pairs = default_pairs();
172 }
173
174 if let Some(review) = review {
175 let writer = review.watched.harness.trim().to_ascii_lowercase();
176 let pair = AdversarialPair {
177 reviewer: HarnessSelection::new(
178 review.reviewer.harness,
179 review.reviewer.model,
180 Effort::highest(),
181 ),
182 arbiter: None,
183 };
184 if had_explicit_pairs {
185 self.pairs.entry(writer).or_insert(pair);
187 } else {
188 self.pairs.insert(writer, pair);
190 }
191 }
192
193 if self.pairs.is_empty() {
194 self.pairs = default_pairs();
195 }
196 }
197
198 fn validate(&self) -> Result<(), ConfigError> {
199 for (writer, pair) in &self.pairs {
200 if let Some(arbiter) = &pair.arbiter
201 && normalized(&arbiter.model) == normalized(&pair.reviewer.model)
202 {
203 return Err(ConfigError::ArbiterNotDistinct {
204 writer: writer.clone(),
205 });
206 }
207 }
208 Ok(())
209 }
210
211 pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
213 self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
214 }
215
216 pub fn default_path(state_dir: &Path) -> PathBuf {
217 state_dir.join("config.toml")
218 }
219}
220
221#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
223pub struct LegacyReview {
224 pub watched: LegacyModel,
225 pub reviewer: LegacyModel,
226}
227
228#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
229pub struct LegacyModel {
230 pub harness: String,
231 pub model: String,
232}
233
234#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
236#[serde(default)]
237pub struct StrictConfig {
238 pub stop_after_lies: u32,
239 pub stop_after_fuckups: u32,
240 pub max_passes: u32,
241}
242
243impl Default for StrictConfig {
244 fn default() -> Self {
245 Self {
246 stop_after_lies: 1,
247 stop_after_fuckups: 3,
248 max_passes: 3,
249 }
250 }
251}
252
253impl StrictConfig {
254 pub fn goal_policy(
255 &self,
256 lies_override: Option<u32>,
257 fuckups_override: Option<u32>,
258 ) -> crate::reviewer::StrictGoalPolicy {
259 crate::reviewer::StrictGoalPolicy {
260 stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
261 stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
262 }
263 }
264}
265
266#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
269#[serde(default)]
270pub struct GatesConfig {
271 pub fake_markers: Vec<String>,
272 pub evidence_patterns: Vec<String>,
273 pub marker_ignore_paths: Vec<String>,
274}
275
276impl Default for GatesConfig {
277 fn default() -> Self {
278 Self {
279 fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
280 evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
281 marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
282 }
283 }
284}
285
286impl GatesConfig {
287 pub fn to_policy(&self) -> crate::claim::GatePolicy {
290 crate::claim::GatePolicy {
291 fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
292 evidence_patterns: union_defaults(
293 &self.evidence_patterns,
294 crate::claim::DEFAULT_EVIDENCE_PATTERNS,
295 ),
296 marker_ignore_paths: union_defaults(
297 &self.marker_ignore_paths,
298 crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
299 ),
300 }
301 }
302}
303
304fn strs(values: &[&str]) -> Vec<String> {
305 values.iter().map(|value| (*value).to_owned()).collect()
306}
307
308fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
312 let mut out: Vec<String> = strs(defaults);
313 for value in values {
314 if !out.iter().any(|existing| existing == value) {
315 out.push(value.clone());
316 }
317 }
318 out
319}
320
321#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
323#[serde(default)]
324pub struct GroundTruthConfig {
325 pub enabled: bool,
326 pub file_names: Vec<String>,
327 pub include_openspec_specs: bool,
328 pub max_bytes: usize,
329}
330
331impl Default for GroundTruthConfig {
332 fn default() -> Self {
333 Self {
334 enabled: true,
335 file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
336 .iter()
337 .map(|name| (*name).to_owned())
338 .collect(),
339 include_openspec_specs: true,
340 max_bytes: 20_000,
341 }
342 }
343}
344
345#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
347#[serde(default)]
348pub struct HistoryConfig {
349 pub window_user: usize,
350 pub window_agent: usize,
351 pub max_bytes: usize,
352 pub transcript_path: Option<String>,
355}
356
357impl Default for HistoryConfig {
358 fn default() -> Self {
359 Self {
360 window_user: 3,
361 window_agent: 10,
362 max_bytes: 12_000,
363 transcript_path: None,
364 }
365 }
366}
367
368#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
370#[serde(default)]
371pub struct EnforcementConfig {
372 pub block_tools_after_unresolved: u32,
373 pub block_tools_after_secs: u64,
374}
375
376impl EnforcementConfig {
377 pub fn is_enabled(&self) -> bool {
378 self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
379 }
380}
381
382#[derive(Debug, Error)]
383pub enum ConfigError {
384 #[error("failed to read config {path}: {source}")]
385 Read {
386 path: PathBuf,
387 #[source]
388 source: io::Error,
389 },
390 #[error("failed to parse config {path}: {source}")]
391 Parse {
392 path: PathBuf,
393 #[source]
394 source: toml::de::Error,
395 },
396 #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
397 ArbiterNotDistinct { writer: String },
398}
399
400fn default_ledger_dir() -> String {
401 ".truth-mirror".to_owned()
402}
403
404fn default_writer() -> String {
405 "codex".to_owned()
406}
407
408fn default_pairs() -> BTreeMap<String, AdversarialPair> {
410 let mut pairs = BTreeMap::new();
411 pairs.insert(
412 "codex".to_owned(),
413 AdversarialPair {
414 reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
415 arbiter: Some(HarnessSelection::new(
416 "pi",
417 "openai-codex/gpt-5.5",
418 Effort::highest(),
419 )),
420 },
421 );
422 pairs.insert(
423 "claude".to_owned(),
424 AdversarialPair {
425 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
426 arbiter: Some(HarnessSelection::new(
427 "pi",
428 "openai-codex/gpt-5.5",
429 Effort::highest(),
430 )),
431 },
432 );
433 pairs.insert(
434 "pi".to_owned(),
435 AdversarialPair {
436 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
437 arbiter: Some(HarnessSelection::new(
438 "claude",
439 "claude-opus-4-8",
440 Effort::highest(),
441 )),
442 },
443 );
444 pairs
445}
446
447fn normalized(model: &str) -> String {
448 model.trim().to_ascii_lowercase()
449}
450
451#[cfg(test)]
452mod tests {
453 use std::path::Path;
454
455 use super::{Effort, TruthMirrorConfig};
456
457 #[test]
458 fn default_config_has_three_opposed_pairs() {
459 let config = TruthMirrorConfig::default();
460
461 assert_eq!(config.pairs.len(), 3);
462 let codex = config.pair_for("codex").unwrap();
463 assert_eq!(codex.reviewer.harness, "claude");
464 assert_eq!(codex.reviewer.model, "claude-opus-4-8");
465 assert_eq!(codex.reviewer.effort, Effort::Xhigh);
466 }
467
468 #[test]
469 fn effort_serializes_lowercase() {
470 assert_eq!(Effort::Xhigh.as_str(), "xhigh");
471 assert_eq!(Effort::highest(), Effort::Xhigh);
472 }
473
474 #[test]
475 fn pairs_config_parses_and_resolves_by_writer() {
476 let contents = r#"
477default_writer = "claude"
478
479[pairs.claude]
480reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
481arbiter = { harness = "pi", model = "openai-codex/gpt-5.5", effort = "high" }
482
483[pairs.codex]
484reviewer = { harness = "claude", model = "claude-opus-4-8" }
485"#;
486 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
487
488 let claude_pair = config.pair_for("claude").unwrap();
489 assert_eq!(claude_pair.reviewer.harness, "codex");
490 assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
491 assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
492
493 let codex_pair = config.pair_for("codex").unwrap();
495 assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
496 }
497
498 #[test]
499 fn legacy_review_block_overrides_default_pair() {
500 let contents = r#"
504ledger_dir = ".truth-mirror"
505
506[review.watched]
507harness = "codex"
508model = "gpt-5.5"
509
510[review.reviewer]
511harness = "gemini"
512model = "gemini-3-pro"
513"#;
514 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
515
516 let pair = config.pair_for("codex").unwrap();
517 assert_eq!(pair.reviewer.harness, "gemini");
518 assert_eq!(pair.reviewer.model, "gemini-3-pro");
519 assert!(config.pair_for("claude").is_some());
521 assert!(config.pair_for("pi").is_some());
522 }
523
524 #[test]
525 fn explicit_pairs_win_over_legacy_review() {
526 let contents = r#"
529[pairs.codex]
530reviewer = { harness = "claude", model = "explicit-model" }
531
532[review.watched]
533harness = "codex"
534model = "gpt-5.5"
535
536[review.reviewer]
537harness = "gemini"
538model = "legacy-model"
539"#;
540 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
541
542 let pair = config.pair_for("codex").unwrap();
543 assert_eq!(pair.reviewer.harness, "claude");
544 assert_eq!(pair.reviewer.model, "explicit-model");
545 }
546
547 #[test]
548 fn explicit_pairs_win_case_insensitively_over_legacy() {
549 let contents = r#"
552[pairs.CODEX]
553reviewer = { harness = "claude", model = "explicit-model" }
554
555[review.watched]
556harness = "codex"
557model = "gpt-5.5"
558
559[review.reviewer]
560harness = "gemini"
561model = "legacy-model"
562"#;
563 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
564
565 let pair = config.pair_for("codex").unwrap();
566 assert_eq!(pair.reviewer.model, "explicit-model");
567 assert_eq!(config.pairs.len(), 1);
569 }
570
571 #[test]
572 fn arbiter_equal_to_reviewer_model_is_rejected() {
573 let contents = r#"
574[pairs.codex]
575reviewer = { harness = "claude", model = "same-model" }
576arbiter = { harness = "pi", model = "same-model" }
577"#;
578 let error =
579 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
580
581 assert!(matches!(
582 error,
583 super::ConfigError::ArbiterNotDistinct { .. }
584 ));
585 }
586
587 #[test]
588 fn missing_config_loads_default() {
589 let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
590
591 assert_eq!(config.pairs.len(), 3);
592 assert!(config.ground_truth.enabled);
593 assert_eq!(config.history.window_user, 3);
594 assert!(!config.enforcement.is_enabled());
595 }
596
597 #[test]
598 fn gates_config_parses_and_builds_policy() {
599 let contents = r#"
600[pairs.codex]
601reviewer = { harness = "claude", model = "claude-opus-4-8" }
602
603[gates]
604fake_markers = ["pretend-pass"]
605evidence_patterns = ["jira:"]
606marker_ignore_paths = [".md", "vendor/"]
607"#;
608 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
609
610 let default_marker = ["mock", "as", "real"].join("-");
615 let policy = config.gates.to_policy();
616 assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
617 assert!(policy.fake_markers.contains(&default_marker));
618 assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
619 assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
620 assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
621 assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
622 }
623
624 #[test]
625 fn empty_gate_lists_fall_back_to_defaults() {
626 let policy = super::GatesConfig {
628 fake_markers: Vec::new(),
629 evidence_patterns: Vec::new(),
630 marker_ignore_paths: Vec::new(),
631 }
632 .to_policy();
633
634 assert!(!policy.fake_markers.is_empty());
635 assert!(!policy.evidence_patterns.is_empty());
636 assert!(!policy.marker_ignore_paths.is_empty());
637 }
638
639 #[test]
640 fn pair_keys_are_lowercased() {
641 let contents = r#"
642[pairs.CODEX]
643reviewer = { harness = "claude", model = "claude-opus-4-8" }
644"#;
645 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
646
647 assert!(config.pair_for("codex").is_some());
648 assert!(config.pair_for("CoDeX").is_some());
649 }
650}