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