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";
15pub const DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS: &[&str] = &[
16 "BEGIN SYSTEM PROMPT",
17 "ignore previous instructions",
18 "api_key",
19 "secret_key",
20 "secret:",
21 "secret=",
22 "token:",
23 "token=",
24 "password:",
25 "password=",
26 "credential:",
27 "credential=",
28 "private_key",
29 "aws_secret_access_key",
30];
31
32#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
34#[serde(rename_all = "lowercase")]
35#[value(rename_all = "lowercase")]
36pub enum Effort {
37 Minimal,
38 Low,
39 Medium,
40 High,
41 #[default]
42 Xhigh,
43}
44
45impl Effort {
46 pub fn as_str(self) -> &'static str {
47 match self {
48 Effort::Minimal => "minimal",
49 Effort::Low => "low",
50 Effort::Medium => "medium",
51 Effort::High => "high",
52 Effort::Xhigh => "xhigh",
53 }
54 }
55
56 pub fn highest() -> Self {
58 Effort::Xhigh
59 }
60
61 pub fn claude_value(self) -> &'static str {
65 match self {
66 Effort::Minimal => "low",
67 other => other.as_str(),
68 }
69 }
70}
71
72#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
74pub struct HarnessSelection {
75 pub harness: String,
76 pub model: String,
77 #[serde(default)]
78 pub effort: Effort,
79}
80
81impl HarnessSelection {
82 pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
83 Self {
84 harness: harness.into(),
85 model: model.into(),
86 effort,
87 }
88 }
89}
90
91#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
93pub struct AdversarialPair {
94 pub reviewer: HarnessSelection,
95 #[serde(default)]
96 pub arbiter: Option<HarnessSelection>,
97}
98
99#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
100pub struct TruthMirrorConfig {
101 #[serde(default = "default_ledger_dir")]
102 pub ledger_dir: String,
103 #[serde(default)]
104 pub allow_same_model: bool,
105 #[serde(default = "default_writer")]
107 pub default_writer: String,
108 #[serde(default)]
111 pub pairs: BTreeMap<String, AdversarialPair>,
112 #[serde(default)]
113 pub strict: StrictConfig,
114 #[serde(default)]
115 pub gates: GatesConfig,
116 #[serde(default)]
117 pub ground_truth: GroundTruthConfig,
118 #[serde(default)]
119 pub history: HistoryConfig,
120 #[serde(default)]
121 pub enforcement: EnforcementConfig,
122 #[serde(default)]
123 pub skills: SkillsConfig,
124 #[serde(default)]
125 pub memory_skill: MemorySkillConfig,
126 #[serde(default, skip_serializing_if = "Option::is_none")]
128 pub review: Option<LegacyReview>,
129}
130
131impl Default for TruthMirrorConfig {
132 fn default() -> Self {
133 Self {
134 ledger_dir: default_ledger_dir(),
135 allow_same_model: false,
136 default_writer: default_writer(),
137 pairs: default_pairs(),
138 strict: StrictConfig::default(),
139 gates: GatesConfig::default(),
140 ground_truth: GroundTruthConfig::default(),
141 history: HistoryConfig::default(),
142 enforcement: EnforcementConfig::default(),
143 skills: SkillsConfig::default(),
144 memory_skill: MemorySkillConfig::default(),
145 review: None,
146 }
147 }
148}
149
150impl TruthMirrorConfig {
151 pub fn load_for_cli(
152 explicit_path: Option<&Path>,
153 state_dir: &Path,
154 ) -> Result<Self, ConfigError> {
155 let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
156 Self::load_or_default(path)
157 }
158
159 pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
160 let path = path.into();
161 match fs::read_to_string(&path) {
162 Ok(contents) => Self::from_toml_str(&path, &contents),
163 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
164 Err(source) => Err(ConfigError::Read { path, source }),
165 }
166 }
167
168 pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
169 let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
170 path: path.to_path_buf(),
171 source,
172 })?;
173 config.normalize();
174 config.validate()?;
175 Ok(config)
176 }
177
178 fn normalize(&mut self) {
182 let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
186 .into_iter()
187 .map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
188 .collect();
189 self.pairs = lowered;
190
191 let had_explicit_pairs = !self.pairs.is_empty();
192 let review = self.review.take();
193
194 if !had_explicit_pairs && review.is_some() {
195 self.pairs = default_pairs();
197 }
198
199 if let Some(review) = review {
200 let writer = review.watched.harness.trim().to_ascii_lowercase();
201 let pair = AdversarialPair {
202 reviewer: HarnessSelection::new(
203 review.reviewer.harness,
204 review.reviewer.model,
205 Effort::highest(),
206 ),
207 arbiter: None,
208 };
209 if had_explicit_pairs {
210 self.pairs.entry(writer).or_insert(pair);
212 } else {
213 self.pairs.insert(writer, pair);
215 }
216 }
217
218 if self.pairs.is_empty() {
219 self.pairs = default_pairs();
220 }
221 }
222
223 fn validate(&self) -> Result<(), ConfigError> {
224 for (writer, pair) in &self.pairs {
225 if let Some(arbiter) = &pair.arbiter
226 && normalized_model(&arbiter.model) == normalized_model(&pair.reviewer.model)
227 {
228 return Err(ConfigError::ArbiterNotDistinct {
229 writer: writer.clone(),
230 });
231 }
232 }
233 if !self.memory_skill.scan.entropy_threshold.is_finite()
234 || self.memory_skill.scan.entropy_threshold <= 0.0
235 {
236 return Err(ConfigError::InvalidMemorySkillEntropyThreshold {
237 value: self.memory_skill.scan.entropy_threshold,
238 });
239 }
240 if self.memory_skill.max_skill_bytes == 0 {
241 return Err(ConfigError::InvalidMemorySkillMaxSkillBytes);
242 }
243 if self.memory_skill.scan.secret_detector_timeout_seconds == 0 {
244 return Err(ConfigError::InvalidMemorySkillSecretDetectorTimeout);
245 }
246 Ok(())
247 }
248
249 pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
251 self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
252 }
253
254 pub fn default_path(state_dir: &Path) -> PathBuf {
255 state_dir.join("config.toml")
256 }
257}
258
259#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
261pub struct LegacyReview {
262 pub watched: LegacyModel,
263 pub reviewer: LegacyModel,
264}
265
266#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
267pub struct LegacyModel {
268 pub harness: String,
269 pub model: String,
270}
271
272#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
274#[serde(default)]
275pub struct StrictConfig {
276 pub stop_after_lies: u32,
277 pub stop_after_fuckups: u32,
278 pub max_passes: u32,
279}
280
281impl Default for StrictConfig {
282 fn default() -> Self {
283 Self {
284 stop_after_lies: 1,
285 stop_after_fuckups: 3,
286 max_passes: 3,
287 }
288 }
289}
290
291impl StrictConfig {
292 pub fn goal_policy(
293 &self,
294 lies_override: Option<u32>,
295 fuckups_override: Option<u32>,
296 ) -> crate::reviewer::StrictGoalPolicy {
297 crate::reviewer::StrictGoalPolicy {
298 stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
299 stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
300 }
301 }
302}
303
304#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
307#[serde(default)]
308pub struct GatesConfig {
309 pub fake_markers: Vec<String>,
310 pub evidence_patterns: Vec<String>,
311 pub marker_ignore_paths: Vec<String>,
312}
313
314impl Default for GatesConfig {
315 fn default() -> Self {
316 Self {
317 fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
318 evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
319 marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
320 }
321 }
322}
323
324impl GatesConfig {
325 pub fn to_policy(&self) -> crate::claim::GatePolicy {
328 crate::claim::GatePolicy {
329 fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
330 evidence_patterns: union_defaults(
331 &self.evidence_patterns,
332 crate::claim::DEFAULT_EVIDENCE_PATTERNS,
333 ),
334 marker_ignore_paths: union_defaults(
335 &self.marker_ignore_paths,
336 crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
337 ),
338 }
339 }
340}
341
342fn strs(values: &[&str]) -> Vec<String> {
343 values.iter().map(|value| (*value).to_owned()).collect()
344}
345
346fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
350 let mut out: Vec<String> = strs(defaults);
351 for value in values {
352 if !out.iter().any(|existing| existing == value) {
353 out.push(value.clone());
354 }
355 }
356 out
357}
358
359#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
361#[serde(default)]
362pub struct GroundTruthConfig {
363 pub enabled: bool,
364 pub file_names: Vec<String>,
365 pub include_openspec_specs: bool,
366 pub max_bytes: usize,
367}
368
369impl Default for GroundTruthConfig {
370 fn default() -> Self {
371 Self {
372 enabled: true,
373 file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
374 .iter()
375 .map(|name| (*name).to_owned())
376 .collect(),
377 include_openspec_specs: true,
378 max_bytes: 20_000,
379 }
380 }
381}
382
383#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
385#[serde(default)]
386pub struct HistoryConfig {
387 pub window_user: usize,
388 pub window_agent: usize,
389 pub max_bytes: usize,
390 pub transcript_path: Option<String>,
393}
394
395impl Default for HistoryConfig {
396 fn default() -> Self {
397 Self {
398 window_user: 3,
399 window_agent: 10,
400 max_bytes: 12_000,
401 transcript_path: None,
402 }
403 }
404}
405
406#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
408#[serde(default)]
409pub struct EnforcementConfig {
410 pub block_tools_after_unresolved: u32,
411 pub block_tools_after_secs: u64,
412}
413
414impl EnforcementConfig {
415 pub fn is_enabled(&self) -> bool {
416 self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
417 }
418}
419
420#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
421#[serde(default)]
422pub struct SkillsConfig {
423 pub enabled: bool,
424}
425
426#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
427#[serde(rename_all = "lowercase")]
428pub enum MemorySkillMode {
429 Off,
430 Suggest,
431 #[default]
432 Stage,
433}
434
435#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
436#[serde(rename_all = "snake_case")]
437pub enum MemorySkillCandidateKind {
438 HowToSkill,
439 AntiPatternSkill,
440 RemediationSkill,
441}
442
443impl MemorySkillCandidateKind {
444 pub fn as_str(self) -> &'static str {
445 match self {
446 Self::HowToSkill => "how_to_skill",
447 Self::AntiPatternSkill => "anti_pattern_skill",
448 Self::RemediationSkill => "remediation_skill",
449 }
450 }
451}
452
453impl std::fmt::Display for MemorySkillCandidateKind {
454 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
455 formatter.write_str(self.as_str())
456 }
457}
458
459#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
460#[serde(default)]
461pub struct MemorySkillConfig {
462 pub enabled: bool,
463 pub mode: MemorySkillMode,
464 pub candidate_dir: String,
465 pub approved_dir: String,
466 pub approved_slug_prefix: String,
467 pub toolbox_delivery_registry: String,
468 pub allow_global_writes: bool,
469 pub require_claim_evidence: bool,
470 pub max_candidates_per_commit: usize,
471 pub max_skill_bytes: usize,
472 pub pre_push_blocks_pending: bool,
473 pub redact_secrets: bool,
478 pub signals: MemorySkillSignalsConfig,
479 pub review: MemorySkillReviewConfig,
480 pub scan: MemorySkillScanConfig,
481}
482
483impl Default for MemorySkillConfig {
484 fn default() -> Self {
485 Self {
486 enabled: false,
487 mode: MemorySkillMode::Stage,
488 candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
489 approved_dir: ".agents/skills".to_owned(),
490 approved_slug_prefix: "generated-".to_owned(),
491 toolbox_delivery_registry: String::new(),
492 allow_global_writes: false,
493 require_claim_evidence: true,
494 max_candidates_per_commit: 1,
495 max_skill_bytes: 12_000,
496 pre_push_blocks_pending: false,
497 redact_secrets: true,
498 signals: MemorySkillSignalsConfig::default(),
499 review: MemorySkillReviewConfig::default(),
500 scan: MemorySkillScanConfig::default(),
501 }
502 }
503}
504
505impl MemorySkillConfig {
506 pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
507 skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
508 }
509}
510
511#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
512#[serde(default)]
513pub struct MemorySkillSignalsConfig {
514 pub min_occurrences: usize,
515 pub require_reusable_procedure: bool,
516 pub capture_passes_as_how_to: bool,
517 pub capture_rejections_as_remediation: bool,
518 pub capture_rejections_as_antipattern: bool,
519 pub rejection_precedence: Vec<MemorySkillCandidateKind>,
520}
521
522impl Default for MemorySkillSignalsConfig {
523 fn default() -> Self {
524 Self {
525 min_occurrences: 2,
526 require_reusable_procedure: true,
527 capture_passes_as_how_to: true,
528 capture_rejections_as_remediation: true,
529 capture_rejections_as_antipattern: true,
530 rejection_precedence: vec![
531 MemorySkillCandidateKind::AntiPatternSkill,
532 MemorySkillCandidateKind::RemediationSkill,
533 ],
534 }
535 }
536}
537
538#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
539#[serde(default)]
540pub struct MemorySkillReviewConfig {
541 pub require_adversarial_review: bool,
542 pub require_reviewed_ledger_entry: bool,
543 pub require_pass_for_how_to: bool,
544 pub require_structured_findings_checked: bool,
545 pub reject_without_ledger_entry: bool,
546}
547
548impl Default for MemorySkillReviewConfig {
549 fn default() -> Self {
550 Self {
551 require_adversarial_review: true,
552 require_reviewed_ledger_entry: true,
553 require_pass_for_how_to: true,
554 require_structured_findings_checked: true,
555 reject_without_ledger_entry: true,
556 }
557 }
558}
559
560#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
561#[serde(default)]
562pub struct MemorySkillScanConfig {
563 pub secret_detection: SecretDetectionMode,
564 pub secret_detector: String,
567 pub allow_secret_detector_command: bool,
568 pub secret_detector_timeout_seconds: u64,
569 pub entropy_threshold: f64,
570 pub blocked_patterns: Vec<String>,
571 pub blocked_patterns_case_insensitive: bool,
572}
573
574impl Default for MemorySkillScanConfig {
575 fn default() -> Self {
576 Self {
577 secret_detection: SecretDetectionMode::Required,
578 secret_detector: String::new(),
579 allow_secret_detector_command: false,
580 secret_detector_timeout_seconds: 10,
581 entropy_threshold: 4.5,
582 blocked_patterns: Vec::new(),
583 blocked_patterns_case_insensitive: true,
584 }
585 }
586}
587
588impl MemorySkillScanConfig {
589 pub fn effective_blocked_patterns(&self) -> Vec<String> {
590 let mut patterns = Vec::new();
591 for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
592 push_unique_pattern(
593 &mut patterns,
594 pattern,
595 self.blocked_patterns_case_insensitive,
596 );
597 }
598 for pattern in &self.blocked_patterns {
599 push_unique_pattern(
600 &mut patterns,
601 pattern,
602 self.blocked_patterns_case_insensitive,
603 );
604 }
605 patterns
606 }
607}
608
609fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
610 let pattern = pattern.trim();
611 if pattern.is_empty()
612 || patterns.iter().any(|existing| {
613 if case_insensitive {
614 existing.eq_ignore_ascii_case(pattern)
615 } else {
616 existing == pattern
617 }
618 })
619 {
620 return;
621 }
622 patterns.push(pattern.to_owned());
623}
624
625#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
626#[serde(rename_all = "kebab-case")]
627pub enum SecretDetectionMode {
628 #[default]
629 Required,
630 BestEffort,
631 Off,
632}
633
634#[derive(Debug, Error)]
635pub enum ConfigError {
636 #[error("failed to read config {path}: {source}")]
637 Read {
638 path: PathBuf,
639 #[source]
640 source: io::Error,
641 },
642 #[error("failed to parse config {path}: {source}")]
643 Parse {
644 path: PathBuf,
645 #[source]
646 source: toml::de::Error,
647 },
648 #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
649 ArbiterNotDistinct { writer: String },
650 #[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
651 InvalidMemorySkillEntropyThreshold { value: f64 },
652 #[error("memory_skill.max_skill_bytes must be greater than 0")]
653 InvalidMemorySkillMaxSkillBytes,
654 #[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
655 InvalidMemorySkillSecretDetectorTimeout,
656}
657
658fn default_ledger_dir() -> String {
659 DEFAULT_STATE_DIR.to_owned()
660}
661
662fn default_writer() -> String {
663 "codex".to_owned()
664}
665
666fn default_pairs() -> BTreeMap<String, AdversarialPair> {
668 let mut pairs = BTreeMap::new();
669 pairs.insert(
670 "codex".to_owned(),
671 AdversarialPair {
672 reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
673 arbiter: Some(HarnessSelection::new(
674 "pi",
675 "openai-codex/gpt-5.5",
676 Effort::highest(),
677 )),
678 },
679 );
680 pairs.insert(
681 "claude".to_owned(),
682 AdversarialPair {
683 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
684 arbiter: Some(HarnessSelection::new(
690 "gemini",
691 "gemini-2.5-pro",
692 Effort::highest(),
693 )),
694 },
695 );
696 pairs.insert(
697 "pi".to_owned(),
698 AdversarialPair {
699 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
700 arbiter: Some(HarnessSelection::new(
701 "claude",
702 "claude-opus-4-8",
703 Effort::highest(),
704 )),
705 },
706 );
707 pairs.insert(
710 "grok".to_owned(),
711 AdversarialPair {
712 reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
713 arbiter: Some(HarnessSelection::new("codex", "gpt-5.5", Effort::highest())),
714 },
715 );
716 pairs
717}
718
719pub(crate) fn normalized_model(model: &str) -> String {
726 let model = model.trim();
727 let short = model
728 .rsplit_once('/')
729 .and_then(|(_, suffix)| {
730 let s = suffix.trim();
731 if s.is_empty() { None } else { Some(s) }
732 })
733 .unwrap_or(model);
734 short.to_ascii_lowercase()
735}
736
737#[cfg(test)]
738mod tests {
739 use std::path::Path;
740
741 use super::{Effort, TruthMirrorConfig};
742
743 #[test]
744 fn default_config_has_four_opposed_pairs() {
745 let config = TruthMirrorConfig::default();
746
747 assert_eq!(config.pairs.len(), 4);
748 assert_eq!(config.ledger_dir, ".truth");
749 assert!(!config.skills.enabled);
750 assert!(!config.memory_skill.enabled);
751 assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
752 let codex = config.pair_for("codex").unwrap();
753 assert_eq!(codex.reviewer.harness, "claude");
754 assert_eq!(codex.reviewer.model, "claude-opus-4-8");
755 assert_eq!(codex.reviewer.effort, Effort::Xhigh);
756 let grok = config.pair_for("grok").unwrap();
757 assert_eq!(grok.reviewer.harness, "claude");
758 assert_eq!(grok.reviewer.model, "claude-opus-4-8");
759 }
760
761 #[test]
762 fn effort_serializes_lowercase() {
763 assert_eq!(Effort::Xhigh.as_str(), "xhigh");
764 assert_eq!(Effort::highest(), Effort::Xhigh);
765 }
766
767 #[test]
768 fn pairs_config_parses_and_resolves_by_writer() {
769 let contents = r#"
772default_writer = "claude"
773
774[pairs.claude]
775reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
776arbiter = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
777
778[pairs.codex]
779reviewer = { harness = "claude", model = "claude-opus-4-8" }
780"#;
781 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
782
783 let claude_pair = config.pair_for("claude").unwrap();
784 assert_eq!(claude_pair.reviewer.harness, "codex");
785 assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
786 assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
787
788 let codex_pair = config.pair_for("codex").unwrap();
790 assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
791 }
792
793 #[test]
794 fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
795 let contents = r#"
798[pairs.claude]
799reviewer = { harness = "codex", model = "gpt-5.5" }
800arbiter = { harness = "pi", model = "openai-codex/gpt-5.5" }
801"#;
802 let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
803 assert!(result.is_err());
804 let err = result.unwrap_err().to_string();
805 assert!(
806 err.contains("arbiter"),
807 "expected arbiter error, got: {err}"
808 );
809 }
810
811 #[test]
812 fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
813 let config = TruthMirrorConfig::default();
819 let pair = config.pair_for("claude").expect("claude pair must exist");
820 let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
821 assert_eq!(
822 arbiter.harness, "gemini",
823 "claude arbiter harness must be gemini"
824 );
825 assert_ne!(
826 super::normalized_model(&arbiter.model),
827 super::normalized_model(&pair.reviewer.model),
828 "claude arbiter must be distinct from reviewer after normalization"
829 );
830 }
831
832 #[test]
833 fn normalized_strips_provider_prefix_for_comparison() {
834 assert_eq!(
836 super::normalized_model("openai-codex/gpt-5.5"),
837 super::normalized_model("gpt-5.5")
838 );
839 assert_ne!(super::normalized_model("/"), super::normalized_model(""));
841 }
842
843 #[test]
844 fn legacy_review_block_overrides_default_pair() {
845 let contents = r#"
849ledger_dir = ".truth-mirror"
850
851[review.watched]
852harness = "codex"
853model = "gpt-5.5"
854
855[review.reviewer]
856harness = "gemini"
857model = "gemini-3-pro"
858"#;
859 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
860
861 let pair = config.pair_for("codex").unwrap();
862 assert_eq!(pair.reviewer.harness, "gemini");
863 assert_eq!(pair.reviewer.model, "gemini-3-pro");
864 assert!(config.pair_for("claude").is_some());
866 assert!(config.pair_for("pi").is_some());
867 assert!(config.pair_for("grok").is_some());
868 }
869
870 #[test]
871 fn explicit_pairs_win_over_legacy_review() {
872 let contents = r#"
875[pairs.codex]
876reviewer = { harness = "claude", model = "explicit-model" }
877
878[review.watched]
879harness = "codex"
880model = "gpt-5.5"
881
882[review.reviewer]
883harness = "gemini"
884model = "legacy-model"
885"#;
886 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
887
888 let pair = config.pair_for("codex").unwrap();
889 assert_eq!(pair.reviewer.harness, "claude");
890 assert_eq!(pair.reviewer.model, "explicit-model");
891 }
892
893 #[test]
894 fn explicit_pairs_win_case_insensitively_over_legacy() {
895 let contents = r#"
898[pairs.CODEX]
899reviewer = { harness = "claude", model = "explicit-model" }
900
901[review.watched]
902harness = "codex"
903model = "gpt-5.5"
904
905[review.reviewer]
906harness = "gemini"
907model = "legacy-model"
908"#;
909 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
910
911 let pair = config.pair_for("codex").unwrap();
912 assert_eq!(pair.reviewer.model, "explicit-model");
913 assert_eq!(config.pairs.len(), 1);
915 }
916
917 #[test]
918 fn arbiter_equal_to_reviewer_model_is_rejected() {
919 let contents = r#"
920[pairs.codex]
921reviewer = { harness = "claude", model = "same-model" }
922arbiter = { harness = "pi", model = "same-model" }
923"#;
924 let error =
925 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
926
927 assert!(matches!(
928 error,
929 super::ConfigError::ArbiterNotDistinct { .. }
930 ));
931 }
932
933 #[test]
934 fn missing_config_loads_default() {
935 let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
936
937 assert_eq!(config.pairs.len(), 4);
938 assert!(config.ground_truth.enabled);
939 assert_eq!(config.history.window_user, 3);
940 assert!(!config.enforcement.is_enabled());
941 }
942
943 #[test]
944 fn gates_config_parses_and_builds_policy() {
945 let contents = r#"
946[pairs.codex]
947reviewer = { harness = "claude", model = "claude-opus-4-8" }
948
949[gates]
950fake_markers = ["pretend-pass"]
951evidence_patterns = ["jira:"]
952marker_ignore_paths = [".md", "vendor/"]
953"#;
954 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
955
956 let default_marker = ["mock", "as", "real"].join("-");
961 let policy = config.gates.to_policy();
962 assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
963 assert!(policy.fake_markers.contains(&default_marker));
964 assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
965 assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
966 assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
967 assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
968 }
969
970 #[test]
971 fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
972 let skills_enabled = super::SkillsConfig { enabled: true };
973 let skills_disabled = super::SkillsConfig { enabled: false };
974 let mut memory = super::MemorySkillConfig {
975 enabled: true,
976 mode: super::MemorySkillMode::Stage,
977 ..super::MemorySkillConfig::default()
978 };
979
980 assert!(memory.effective_enabled(&skills_enabled));
981 assert!(!memory.effective_enabled(&skills_disabled));
982
983 memory.enabled = false;
984 assert!(!memory.effective_enabled(&skills_enabled));
985
986 memory.enabled = true;
987 memory.mode = super::MemorySkillMode::Off;
988 assert!(!memory.effective_enabled(&skills_enabled));
989 }
990
991 #[test]
992 fn memory_skill_rejection_precedence_is_legacy_compatible() {
993 let contents = r#"
994[memory_skill.signals]
995rejection_precedence = ["how_to_skill"]
996"#;
997
998 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
999
1000 assert_eq!(
1001 config.memory_skill.signals.rejection_precedence,
1002 vec![super::MemorySkillCandidateKind::HowToSkill]
1003 );
1004 }
1005
1006 #[test]
1007 fn memory_skill_entropy_threshold_must_be_finite() {
1008 let contents = r#"
1009[memory_skill.scan]
1010entropy_threshold = inf
1011"#;
1012
1013 let error =
1014 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1015
1016 assert!(matches!(
1017 error,
1018 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1019 ));
1020 }
1021
1022 #[test]
1023 fn memory_skill_entropy_threshold_must_be_positive() {
1024 let contents = r#"
1025[memory_skill.scan]
1026entropy_threshold = 0.0
1027"#;
1028
1029 let error =
1030 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1031
1032 assert!(matches!(
1033 error,
1034 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1035 ));
1036 }
1037
1038 #[test]
1039 fn memory_skill_max_skill_bytes_must_be_positive() {
1040 let contents = r#"
1041[memory_skill]
1042max_skill_bytes = 0
1043"#;
1044
1045 let error =
1046 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1047
1048 assert!(matches!(
1049 error,
1050 super::ConfigError::InvalidMemorySkillMaxSkillBytes
1051 ));
1052 }
1053
1054 #[test]
1055 fn memory_skill_secret_detector_timeout_must_be_positive() {
1056 let contents = r#"
1057[memory_skill.scan]
1058secret_detector_timeout_seconds = 0
1059"#;
1060
1061 let error =
1062 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1063
1064 assert!(matches!(
1065 error,
1066 super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
1067 ));
1068 }
1069
1070 #[test]
1071 fn empty_gate_lists_fall_back_to_defaults() {
1072 let policy = super::GatesConfig {
1074 fake_markers: Vec::new(),
1075 evidence_patterns: Vec::new(),
1076 marker_ignore_paths: Vec::new(),
1077 }
1078 .to_policy();
1079
1080 assert!(!policy.fake_markers.is_empty());
1081 assert!(!policy.evidence_patterns.is_empty());
1082 assert!(!policy.marker_ignore_paths.is_empty());
1083 }
1084
1085 #[test]
1086 fn memory_skill_blocked_patterns_trim_and_dedupe() {
1087 let config = super::MemorySkillScanConfig {
1088 blocked_patterns: vec![
1089 " API_KEY ".to_owned(),
1090 " custom ".to_owned(),
1091 " ".to_owned(),
1092 "custom".to_owned(),
1093 ],
1094 ..super::MemorySkillScanConfig::default()
1095 };
1096
1097 let patterns = config.effective_blocked_patterns();
1098
1099 assert_eq!(
1100 patterns
1101 .iter()
1102 .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1103 .count(),
1104 1
1105 );
1106 assert_eq!(
1107 patterns
1108 .iter()
1109 .filter(|pattern| *pattern == "custom")
1110 .count(),
1111 1
1112 );
1113 assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1114 }
1115
1116 #[test]
1117 fn pair_keys_are_lowercased() {
1118 let contents = r#"
1119[pairs.CODEX]
1120reviewer = { harness = "claude", model = "claude-opus-4-8" }
1121"#;
1122 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1123
1124 assert!(config.pair_for("codex").is_some());
1125 assert!(config.pair_for("CoDeX").is_some());
1126 }
1127}