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 MAX_PETITION_BATCH_SIZE: usize = 32;
16
17pub const MAX_CONCURRENT_REVIEW_RUNS: usize = 4;
19pub const DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS: &[&str] = &[
20 "BEGIN SYSTEM PROMPT",
21 "ignore previous instructions",
22 "api_key",
23 "secret_key",
24 "secret:",
25 "secret=",
26 "token:",
27 "token=",
28 "password:",
29 "password=",
30 "credential:",
31 "credential=",
32 "private_key",
33 "aws_secret_access_key",
34];
35
36#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
38#[serde(rename_all = "lowercase")]
39#[value(rename_all = "lowercase")]
40pub enum Effort {
41 Minimal,
42 Low,
43 Medium,
44 High,
45 #[default]
46 Xhigh,
47}
48
49impl Effort {
50 pub fn as_str(self) -> &'static str {
51 match self {
52 Effort::Minimal => "minimal",
53 Effort::Low => "low",
54 Effort::Medium => "medium",
55 Effort::High => "high",
56 Effort::Xhigh => "xhigh",
57 }
58 }
59
60 pub fn highest() -> Self {
62 Effort::Xhigh
63 }
64
65 pub fn claude_value(self) -> &'static str {
69 match self {
70 Effort::Minimal => "low",
71 other => other.as_str(),
72 }
73 }
74}
75
76#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
78pub struct HarnessSelection {
79 pub harness: String,
80 pub model: String,
81 #[serde(default)]
82 pub effort: Effort,
83}
84
85impl HarnessSelection {
86 pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
87 Self {
88 harness: harness.into(),
89 model: model.into(),
90 effort,
91 }
92 }
93}
94
95#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
97pub struct AdversarialPair {
98 pub reviewer: HarnessSelection,
99 #[serde(default)]
100 pub arbiter: Option<HarnessSelection>,
101}
102
103#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
104pub struct TruthMirrorConfig {
105 #[serde(default = "default_ledger_dir")]
106 pub ledger_dir: String,
107 #[serde(default)]
108 pub allow_same_model: bool,
109 #[serde(default = "default_writer")]
111 pub default_writer: String,
112 #[serde(default)]
115 pub pairs: BTreeMap<String, AdversarialPair>,
116 #[serde(default)]
117 pub strict: StrictConfig,
118 #[serde(default)]
119 pub gates: GatesConfig,
120 #[serde(default)]
121 pub ground_truth: GroundTruthConfig,
122 #[serde(default)]
123 pub history: HistoryConfig,
124 #[serde(default)]
125 pub enforcement: EnforcementConfig,
126 #[serde(default)]
127 pub skills: SkillsConfig,
128 #[serde(default)]
129 pub memory_skill: MemorySkillConfig,
130 #[serde(default)]
132 pub reviewer: ReviewerPolicyConfig,
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub review: Option<LegacyReview>,
136}
137
138impl Default for TruthMirrorConfig {
139 fn default() -> Self {
140 Self {
141 ledger_dir: default_ledger_dir(),
142 allow_same_model: false,
143 default_writer: default_writer(),
144 pairs: default_pairs(),
145 strict: StrictConfig::default(),
146 gates: GatesConfig::default(),
147 ground_truth: GroundTruthConfig::default(),
148 history: HistoryConfig::default(),
149 enforcement: EnforcementConfig::default(),
150 skills: SkillsConfig::default(),
151 memory_skill: MemorySkillConfig::default(),
152 reviewer: ReviewerPolicyConfig::default(),
153 review: None,
154 }
155 }
156}
157
158impl TruthMirrorConfig {
159 pub fn load_for_cli(
160 explicit_path: Option<&Path>,
161 state_dir: &Path,
162 ) -> Result<Self, ConfigError> {
163 let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
164 Self::load_or_default(path)
165 }
166
167 pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
168 let path = path.into();
169 match fs::read_to_string(&path) {
170 Ok(contents) => Self::from_toml_str(&path, &contents),
171 Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
172 Err(source) => Err(ConfigError::Read { path, source }),
173 }
174 }
175
176 pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
177 let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
178 path: path.to_path_buf(),
179 source,
180 })?;
181 config.normalize();
182 config.validate()?;
183 Ok(config)
184 }
185
186 fn normalize(&mut self) {
190 let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
194 .into_iter()
195 .map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
196 .collect();
197 self.pairs = lowered;
198
199 let had_explicit_pairs = !self.pairs.is_empty();
200 let review = self.review.take();
201
202 if !had_explicit_pairs && review.is_some() {
203 self.pairs = default_pairs();
205 }
206
207 if let Some(review) = review {
208 let writer = review.watched.harness.trim().to_ascii_lowercase();
209 let pair = AdversarialPair {
210 reviewer: HarnessSelection::new(
211 review.reviewer.harness,
212 review.reviewer.model,
213 Effort::highest(),
214 ),
215 arbiter: None,
216 };
217 if had_explicit_pairs {
218 self.pairs.entry(writer).or_insert(pair);
220 } else {
221 self.pairs.insert(writer, pair);
223 }
224 }
225
226 if self.pairs.is_empty() {
227 self.pairs = default_pairs();
228 }
229 }
230
231 fn validate(&self) -> Result<(), ConfigError> {
232 for (writer, pair) in &self.pairs {
233 if let Some(arbiter) = &pair.arbiter
234 && normalized_model(&arbiter.model) == normalized_model(&pair.reviewer.model)
235 {
236 return Err(ConfigError::ArbiterNotDistinct {
237 writer: writer.clone(),
238 });
239 }
240 }
241 if !self.memory_skill.scan.entropy_threshold.is_finite()
242 || self.memory_skill.scan.entropy_threshold <= 0.0
243 {
244 return Err(ConfigError::InvalidMemorySkillEntropyThreshold {
245 value: self.memory_skill.scan.entropy_threshold,
246 });
247 }
248 if !self.memory_skill.signals.similarity_threshold.is_finite()
249 || !(0.0..=1.0).contains(&self.memory_skill.signals.similarity_threshold)
250 {
251 return Err(ConfigError::InvalidMemorySkillSimilarityThreshold {
252 value: self.memory_skill.signals.similarity_threshold,
253 });
254 }
255 if !(1..=MAX_PETITION_BATCH_SIZE).contains(&self.reviewer.max_petition_batch_size) {
256 return Err(ConfigError::InvalidPetitionBatchSize {
257 value: self.reviewer.max_petition_batch_size,
258 });
259 }
260 if !(1..=MAX_CONCURRENT_REVIEW_RUNS).contains(&self.reviewer.max_concurrent_runs) {
261 return Err(ConfigError::InvalidConcurrentReviewRuns {
262 value: self.reviewer.max_concurrent_runs,
263 });
264 }
265
266 if self.memory_skill.max_skill_bytes == 0 {
267 return Err(ConfigError::InvalidMemorySkillMaxSkillBytes);
268 }
269 if self.memory_skill.scan.secret_detector_timeout_seconds == 0 {
270 return Err(ConfigError::InvalidMemorySkillSecretDetectorTimeout);
271 }
272 Ok(())
273 }
274
275 pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
277 self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
278 }
279
280 pub fn default_path(state_dir: &Path) -> PathBuf {
281 state_dir.join("config.toml")
282 }
283}
284
285pub const DEFAULT_REVIEWER_TIMEOUT_SECS: u64 = 20 * 60;
290
291#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
293#[serde(default)]
294pub struct ReviewerPolicyConfig {
295 pub batch_petitions: bool,
297 pub max_petition_batch_size: usize,
298 pub timeout_secs: u64,
303 pub max_concurrent_runs: usize,
306}
307
308impl Default for ReviewerPolicyConfig {
309 fn default() -> Self {
310 Self {
311 batch_petitions: false,
312 max_petition_batch_size: MAX_PETITION_BATCH_SIZE,
313 timeout_secs: DEFAULT_REVIEWER_TIMEOUT_SECS,
314 max_concurrent_runs: 1,
315 }
316 }
317}
318
319impl ReviewerPolicyConfig {
320 pub fn timeout(&self) -> Option<std::time::Duration> {
322 (self.timeout_secs > 0).then(|| std::time::Duration::from_secs(self.timeout_secs))
323 }
324}
325
326#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
328pub struct LegacyReview {
329 pub watched: LegacyModel,
330 pub reviewer: LegacyModel,
331}
332
333#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
334pub struct LegacyModel {
335 pub harness: String,
336 pub model: String,
337}
338
339#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
341#[serde(default)]
342pub struct StrictConfig {
343 pub stop_after_lies: u32,
344 pub stop_after_fuckups: u32,
345 pub max_passes: u32,
346}
347
348impl Default for StrictConfig {
349 fn default() -> Self {
350 Self {
351 stop_after_lies: 1,
352 stop_after_fuckups: 3,
353 max_passes: 3,
354 }
355 }
356}
357
358impl StrictConfig {
359 pub fn goal_policy(
360 &self,
361 lies_override: Option<u32>,
362 fuckups_override: Option<u32>,
363 ) -> crate::reviewer::StrictGoalPolicy {
364 crate::reviewer::StrictGoalPolicy {
365 stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
366 stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
367 }
368 }
369}
370
371#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
374#[serde(default)]
375pub struct GatesConfig {
376 pub fake_markers: Vec<String>,
377 pub evidence_patterns: Vec<String>,
378 pub marker_ignore_paths: Vec<String>,
379}
380
381impl Default for GatesConfig {
382 fn default() -> Self {
383 Self {
384 fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
385 evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
386 marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
387 }
388 }
389}
390
391impl GatesConfig {
392 pub fn to_policy(&self) -> crate::claim::GatePolicy {
395 crate::claim::GatePolicy {
396 fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
397 evidence_patterns: union_defaults(
398 &self.evidence_patterns,
399 crate::claim::DEFAULT_EVIDENCE_PATTERNS,
400 ),
401 marker_ignore_paths: union_defaults(
402 &self.marker_ignore_paths,
403 crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
404 ),
405 }
406 }
407}
408
409fn strs(values: &[&str]) -> Vec<String> {
410 values.iter().map(|value| (*value).to_owned()).collect()
411}
412
413fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
417 let mut out: Vec<String> = strs(defaults);
418 for value in values {
419 if !out.iter().any(|existing| existing == value) {
420 out.push(value.clone());
421 }
422 }
423 out
424}
425
426#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
428#[serde(default)]
429pub struct GroundTruthConfig {
430 pub enabled: bool,
431 pub file_names: Vec<String>,
432 pub include_openspec_specs: bool,
433 pub max_bytes: usize,
434}
435
436impl Default for GroundTruthConfig {
437 fn default() -> Self {
438 Self {
439 enabled: true,
440 file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
441 .iter()
442 .map(|name| (*name).to_owned())
443 .collect(),
444 include_openspec_specs: true,
445 max_bytes: 20_000,
446 }
447 }
448}
449
450#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
452#[serde(default)]
453pub struct HistoryConfig {
454 pub window_user: usize,
455 pub window_agent: usize,
456 pub max_bytes: usize,
457 pub transcript_path: Option<String>,
460}
461
462impl Default for HistoryConfig {
463 fn default() -> Self {
464 Self {
465 window_user: 3,
466 window_agent: 10,
467 max_bytes: 12_000,
468 transcript_path: None,
469 }
470 }
471}
472
473#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
475#[serde(default)]
476pub struct EnforcementConfig {
477 pub block_tools_after_unresolved: u32,
478 pub block_tools_after_secs: u64,
479}
480
481impl EnforcementConfig {
482 pub fn is_enabled(&self) -> bool {
483 self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
484 }
485}
486
487#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
488#[serde(default)]
489pub struct SkillsConfig {
490 pub enabled: bool,
491}
492
493impl Default for SkillsConfig {
494 fn default() -> Self {
495 Self { enabled: true }
496 }
497}
498
499#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
500#[serde(rename_all = "lowercase")]
501pub enum MemorySkillMode {
502 Off,
503 Suggest,
504 #[default]
505 Stage,
506}
507
508#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
509#[serde(rename_all = "snake_case")]
510pub enum MemorySkillCandidateKind {
511 HowToSkill,
512 AntiPatternSkill,
513 RemediationSkill,
514}
515
516impl MemorySkillCandidateKind {
517 pub fn as_str(self) -> &'static str {
518 match self {
519 Self::HowToSkill => "how_to_skill",
520 Self::AntiPatternSkill => "anti_pattern_skill",
521 Self::RemediationSkill => "remediation_skill",
522 }
523 }
524}
525
526impl std::fmt::Display for MemorySkillCandidateKind {
527 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
528 formatter.write_str(self.as_str())
529 }
530}
531
532#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
533#[serde(default)]
534pub struct MemorySkillConfig {
535 pub enabled: bool,
536 pub mode: MemorySkillMode,
537 pub candidate_dir: String,
538 pub approved_dir: String,
539 pub approved_slug_prefix: String,
540 pub toolbox_delivery_registry: String,
541 pub allow_global_writes: bool,
542 pub require_claim_evidence: bool,
543 pub max_candidates_per_commit: usize,
544 pub max_skill_bytes: usize,
545 pub pre_push_blocks_pending: bool,
546 pub redact_secrets: bool,
551 pub signals: MemorySkillSignalsConfig,
552 pub review: MemorySkillReviewConfig,
553 pub scan: MemorySkillScanConfig,
554}
555
556impl Default for MemorySkillConfig {
557 fn default() -> Self {
558 Self {
559 enabled: true,
560 mode: MemorySkillMode::Stage,
561 candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
562 approved_dir: ".agents/skills".to_owned(),
563 approved_slug_prefix: "generated-".to_owned(),
564 toolbox_delivery_registry: String::new(),
565 allow_global_writes: false,
566 require_claim_evidence: true,
567 max_candidates_per_commit: 1,
568 max_skill_bytes: 12_000,
569 pre_push_blocks_pending: false,
570 redact_secrets: true,
571 signals: MemorySkillSignalsConfig::default(),
572 review: MemorySkillReviewConfig::default(),
573 scan: MemorySkillScanConfig::default(),
574 }
575 }
576}
577
578impl MemorySkillConfig {
579 pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
580 skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
581 }
582}
583
584#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
585#[serde(default)]
586pub struct MemorySkillSignalsConfig {
587 pub similarity_threshold: f64,
588 pub min_occurrences: usize,
589 pub require_reusable_procedure: bool,
590 pub capture_passes_as_how_to: bool,
591 pub capture_rejections_as_remediation: bool,
592 pub capture_rejections_as_antipattern: bool,
593 pub rejection_precedence: Vec<MemorySkillCandidateKind>,
594}
595
596impl Default for MemorySkillSignalsConfig {
597 fn default() -> Self {
598 Self {
599 similarity_threshold: 0.55,
600 min_occurrences: 2,
601 require_reusable_procedure: true,
602 capture_passes_as_how_to: true,
603 capture_rejections_as_remediation: true,
604 capture_rejections_as_antipattern: true,
605 rejection_precedence: vec![
606 MemorySkillCandidateKind::AntiPatternSkill,
607 MemorySkillCandidateKind::RemediationSkill,
608 ],
609 }
610 }
611}
612
613#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
614#[serde(default)]
615pub struct MemorySkillReviewConfig {
616 pub require_adversarial_review: bool,
617 pub require_reviewed_ledger_entry: bool,
618 pub require_pass_for_how_to: bool,
619 pub require_structured_findings_checked: bool,
620 pub reject_without_ledger_entry: bool,
621}
622
623impl Default for MemorySkillReviewConfig {
624 fn default() -> Self {
625 Self {
626 require_adversarial_review: true,
627 require_reviewed_ledger_entry: true,
628 require_pass_for_how_to: true,
629 require_structured_findings_checked: true,
630 reject_without_ledger_entry: true,
631 }
632 }
633}
634
635#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
636#[serde(default)]
637pub struct MemorySkillScanConfig {
638 pub secret_detection: SecretDetectionMode,
639 pub secret_detector: String,
642 pub allow_secret_detector_command: bool,
643 pub secret_detector_timeout_seconds: u64,
644 pub entropy_threshold: f64,
645 pub blocked_patterns: Vec<String>,
646 pub blocked_patterns_case_insensitive: bool,
647}
648
649impl Default for MemorySkillScanConfig {
650 fn default() -> Self {
651 Self {
652 secret_detection: SecretDetectionMode::Required,
653 secret_detector: String::new(),
654 allow_secret_detector_command: false,
655 secret_detector_timeout_seconds: 10,
656 entropy_threshold: 4.5,
657 blocked_patterns: Vec::new(),
658 blocked_patterns_case_insensitive: true,
659 }
660 }
661}
662
663impl MemorySkillScanConfig {
664 pub fn effective_blocked_patterns(&self) -> Vec<String> {
665 let mut patterns = Vec::new();
666 for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
667 push_unique_pattern(
668 &mut patterns,
669 pattern,
670 self.blocked_patterns_case_insensitive,
671 );
672 }
673 for pattern in &self.blocked_patterns {
674 push_unique_pattern(
675 &mut patterns,
676 pattern,
677 self.blocked_patterns_case_insensitive,
678 );
679 }
680 patterns
681 }
682}
683
684fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
685 let pattern = pattern.trim();
686 if pattern.is_empty()
687 || patterns.iter().any(|existing| {
688 if case_insensitive {
689 existing.eq_ignore_ascii_case(pattern)
690 } else {
691 existing == pattern
692 }
693 })
694 {
695 return;
696 }
697 patterns.push(pattern.to_owned());
698}
699
700#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
701#[serde(rename_all = "kebab-case")]
702pub enum SecretDetectionMode {
703 #[default]
704 Required,
705 BestEffort,
706 Off,
707}
708
709#[derive(Debug, Error)]
710pub enum ConfigError {
711 #[error("failed to read config {path}: {source}")]
712 Read {
713 path: PathBuf,
714 #[source]
715 source: io::Error,
716 },
717 #[error("failed to parse config {path}: {source}")]
718 Parse {
719 path: PathBuf,
720 #[source]
721 source: toml::de::Error,
722 },
723 #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
724 ArbiterNotDistinct { writer: String },
725 #[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
726 InvalidMemorySkillEntropyThreshold { value: f64 },
727 #[error(
728 "memory_skill.signals.similarity_threshold must be finite and between 0.0 and 1.0, got {value}"
729 )]
730 InvalidMemorySkillSimilarityThreshold { value: f64 },
731 #[error(
732 "reviewer.max_petition_batch_size must be between 1 and {MAX_PETITION_BATCH_SIZE}, got {value}"
733 )]
734 InvalidPetitionBatchSize { value: usize },
735 #[error(
736 "reviewer.max_concurrent_runs must be between 1 and {MAX_CONCURRENT_REVIEW_RUNS}, got {value}"
737 )]
738 InvalidConcurrentReviewRuns { value: usize },
739 #[error("memory_skill.max_skill_bytes must be greater than 0")]
740 InvalidMemorySkillMaxSkillBytes,
741 #[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
742 InvalidMemorySkillSecretDetectorTimeout,
743}
744
745fn default_ledger_dir() -> String {
746 DEFAULT_STATE_DIR.to_owned()
747}
748
749fn default_writer() -> String {
750 "codex".to_owned()
751}
752
753fn default_pairs() -> BTreeMap<String, AdversarialPair> {
755 let mut pairs = BTreeMap::new();
756 pairs.insert(
757 "codex".to_owned(),
758 AdversarialPair {
759 reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
760 arbiter: Some(HarnessSelection::new(
761 "pi",
762 "openai-codex/gpt-5.5",
763 Effort::highest(),
764 )),
765 },
766 );
767 pairs.insert(
768 "claude".to_owned(),
769 AdversarialPair {
770 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
771 arbiter: Some(HarnessSelection::new(
777 "gemini",
778 "gemini-2.5-pro",
779 Effort::highest(),
780 )),
781 },
782 );
783 pairs.insert(
784 "pi".to_owned(),
785 AdversarialPair {
786 reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
787 arbiter: Some(HarnessSelection::new(
788 "claude",
789 "claude-opus-4-8",
790 Effort::highest(),
791 )),
792 },
793 );
794 pairs.insert(
797 "grok".to_owned(),
798 AdversarialPair {
799 reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
800 arbiter: Some(HarnessSelection::new("codex", "gpt-5.5", Effort::highest())),
801 },
802 );
803 pairs
804}
805
806pub(crate) fn normalized_model(model: &str) -> String {
813 let model = model.trim();
814 let short = model
815 .rsplit_once('/')
816 .and_then(|(_, suffix)| {
817 let s = suffix.trim();
818 if s.is_empty() { None } else { Some(s) }
819 })
820 .unwrap_or(model);
821 short.to_ascii_lowercase()
822}
823
824#[cfg(test)]
825mod tests {
826 use std::path::Path;
827
828 use super::{Effort, TruthMirrorConfig};
829
830 #[test]
831 fn default_config_has_four_opposed_pairs() {
832 let config = TruthMirrorConfig::default();
833
834 assert_eq!(config.pairs.len(), 4);
835 assert_eq!(config.ledger_dir, ".truth");
836 assert!(config.skills.enabled);
837 assert!(config.memory_skill.enabled);
838 assert!(config.memory_skill.effective_enabled(&config.skills));
839 assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
840 assert_eq!(config.memory_skill.signals.similarity_threshold, 0.55);
841 let codex = config.pair_for("codex").unwrap();
842 assert_eq!(codex.reviewer.harness, "claude");
843 assert_eq!(codex.reviewer.model, "claude-opus-4-8");
844 assert_eq!(codex.reviewer.effort, Effort::Xhigh);
845 let grok = config.pair_for("grok").unwrap();
846 assert_eq!(grok.reviewer.harness, "claude");
847 assert_eq!(grok.reviewer.model, "claude-opus-4-8");
848 }
849
850 #[test]
851 fn reviewer_batching_defaults_to_legacy_serial_petitions() {
852 let config = TruthMirrorConfig::default();
853
854 assert!(!config.reviewer.batch_petitions);
855 assert_eq!(
856 config.reviewer.max_petition_batch_size,
857 super::MAX_PETITION_BATCH_SIZE
858 );
859 assert_eq!(config.reviewer.max_concurrent_runs, 1);
860 }
861
862 #[test]
863 fn reviewer_batching_policy_parses_when_explicitly_enabled() {
864 let contents = r#"
865[reviewer]
866batch_petitions = true
867max_petition_batch_size = 7
868max_concurrent_runs = 2
869"#;
870
871 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
872
873 assert!(config.reviewer.batch_petitions);
874 assert_eq!(config.reviewer.max_petition_batch_size, 7);
875 assert_eq!(config.reviewer.max_concurrent_runs, 2);
876 }
877
878 #[test]
879 fn reviewer_concurrency_rejects_zero_and_values_above_the_bound() {
880 for value in [0, super::MAX_CONCURRENT_REVIEW_RUNS + 1] {
881 let contents = format!("[reviewer]\nmax_concurrent_runs = {value}\n");
882
883 let error =
884 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
885
886 assert!(matches!(
887 error,
888 super::ConfigError::InvalidConcurrentReviewRuns { value: invalid }
889 if invalid == value
890 ));
891 }
892 }
893
894 #[test]
895 fn reviewer_batch_size_rejects_zero_and_values_above_the_bound() {
896 for value in [0, super::MAX_PETITION_BATCH_SIZE + 1] {
897 let contents =
898 format!("[reviewer]\nbatch_petitions = true\nmax_petition_batch_size = {value}\n");
899
900 let error =
901 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
902
903 assert!(matches!(
904 error,
905 super::ConfigError::InvalidPetitionBatchSize { value: invalid }
906 if invalid == value
907 ));
908 }
909 }
910
911 #[test]
912 fn reviewer_timeout_defaults_to_twenty_minutes() {
913 let config = TruthMirrorConfig::default();
914
915 assert_eq!(
916 config.reviewer.timeout_secs,
917 super::DEFAULT_REVIEWER_TIMEOUT_SECS
918 );
919 assert_eq!(
920 config.reviewer.timeout(),
921 Some(std::time::Duration::from_secs(20 * 60))
922 );
923 }
924
925 #[test]
926 fn reviewer_timeout_parses_and_zero_disables() {
927 let contents = "[reviewer]\ntimeout_secs = 90\n";
930 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
931 assert_eq!(
932 config.reviewer.timeout(),
933 Some(std::time::Duration::from_secs(90))
934 );
935
936 let disabled = "[reviewer]\ntimeout_secs = 0\n";
937 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), disabled).unwrap();
938 assert_eq!(config.reviewer.timeout(), None);
939 }
940
941 #[test]
942 fn effort_serializes_lowercase() {
943 assert_eq!(Effort::Xhigh.as_str(), "xhigh");
944 assert_eq!(Effort::highest(), Effort::Xhigh);
945 }
946
947 #[test]
948 fn pairs_config_parses_and_resolves_by_writer() {
949 let contents = r#"
952default_writer = "claude"
953
954[pairs.claude]
955reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
956arbiter = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
957
958[pairs.codex]
959reviewer = { harness = "claude", model = "claude-opus-4-8" }
960"#;
961 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
962
963 let claude_pair = config.pair_for("claude").unwrap();
964 assert_eq!(claude_pair.reviewer.harness, "codex");
965 assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
966 assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
967
968 let codex_pair = config.pair_for("codex").unwrap();
970 assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
971 }
972
973 #[test]
974 fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
975 let contents = r#"
978[pairs.claude]
979reviewer = { harness = "codex", model = "gpt-5.5" }
980arbiter = { harness = "pi", model = "openai-codex/gpt-5.5" }
981"#;
982 let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
983 assert!(result.is_err());
984 let err = result.unwrap_err().to_string();
985 assert!(
986 err.contains("arbiter"),
987 "expected arbiter error, got: {err}"
988 );
989 }
990
991 #[test]
992 fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
993 let config = TruthMirrorConfig::default();
999 let pair = config.pair_for("claude").expect("claude pair must exist");
1000 let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
1001 assert_eq!(
1002 arbiter.harness, "gemini",
1003 "claude arbiter harness must be gemini"
1004 );
1005 assert_ne!(
1006 super::normalized_model(&arbiter.model),
1007 super::normalized_model(&pair.reviewer.model),
1008 "claude arbiter must be distinct from reviewer after normalization"
1009 );
1010 }
1011
1012 #[test]
1013 fn normalized_strips_provider_prefix_for_comparison() {
1014 assert_eq!(
1016 super::normalized_model("openai-codex/gpt-5.5"),
1017 super::normalized_model("gpt-5.5")
1018 );
1019 assert_ne!(super::normalized_model("/"), super::normalized_model(""));
1021 }
1022
1023 #[test]
1024 fn legacy_review_block_overrides_default_pair() {
1025 let contents = r#"
1029ledger_dir = ".truth-mirror"
1030
1031[review.watched]
1032harness = "codex"
1033model = "gpt-5.5"
1034
1035[review.reviewer]
1036harness = "gemini"
1037model = "gemini-3-pro"
1038"#;
1039 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1040
1041 let pair = config.pair_for("codex").unwrap();
1042 assert_eq!(pair.reviewer.harness, "gemini");
1043 assert_eq!(pair.reviewer.model, "gemini-3-pro");
1044 assert!(config.pair_for("claude").is_some());
1046 assert!(config.pair_for("pi").is_some());
1047 assert!(config.pair_for("grok").is_some());
1048 }
1049
1050 #[test]
1051 fn explicit_pairs_win_over_legacy_review() {
1052 let contents = r#"
1055[pairs.codex]
1056reviewer = { harness = "claude", model = "explicit-model" }
1057
1058[review.watched]
1059harness = "codex"
1060model = "gpt-5.5"
1061
1062[review.reviewer]
1063harness = "gemini"
1064model = "legacy-model"
1065"#;
1066 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1067
1068 let pair = config.pair_for("codex").unwrap();
1069 assert_eq!(pair.reviewer.harness, "claude");
1070 assert_eq!(pair.reviewer.model, "explicit-model");
1071 }
1072
1073 #[test]
1074 fn explicit_pairs_win_case_insensitively_over_legacy() {
1075 let contents = r#"
1078[pairs.CODEX]
1079reviewer = { harness = "claude", model = "explicit-model" }
1080
1081[review.watched]
1082harness = "codex"
1083model = "gpt-5.5"
1084
1085[review.reviewer]
1086harness = "gemini"
1087model = "legacy-model"
1088"#;
1089 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1090
1091 let pair = config.pair_for("codex").unwrap();
1092 assert_eq!(pair.reviewer.model, "explicit-model");
1093 assert_eq!(config.pairs.len(), 1);
1095 }
1096
1097 #[test]
1098 fn arbiter_equal_to_reviewer_model_is_rejected() {
1099 let contents = r#"
1100[pairs.codex]
1101reviewer = { harness = "claude", model = "same-model" }
1102arbiter = { harness = "pi", model = "same-model" }
1103"#;
1104 let error =
1105 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1106
1107 assert!(matches!(
1108 error,
1109 super::ConfigError::ArbiterNotDistinct { .. }
1110 ));
1111 }
1112
1113 #[test]
1114 fn missing_config_loads_default() {
1115 let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
1116
1117 assert_eq!(config.pairs.len(), 4);
1118 assert!(config.ground_truth.enabled);
1119 assert_eq!(config.history.window_user, 3);
1120 assert!(!config.enforcement.is_enabled());
1121 }
1122
1123 #[test]
1124 fn gates_config_parses_and_builds_policy() {
1125 let contents = r#"
1126[pairs.codex]
1127reviewer = { harness = "claude", model = "claude-opus-4-8" }
1128
1129[gates]
1130fake_markers = ["pretend-pass"]
1131evidence_patterns = ["jira:"]
1132marker_ignore_paths = [".md", "vendor/"]
1133"#;
1134 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1135
1136 let default_marker = ["mock", "as", "real"].join("-");
1141 let policy = config.gates.to_policy();
1142 assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
1143 assert!(policy.fake_markers.contains(&default_marker));
1144 assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
1145 assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
1146 assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
1147 assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
1148 }
1149
1150 #[test]
1151 fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
1152 let skills_enabled = super::SkillsConfig { enabled: true };
1153 let skills_disabled = super::SkillsConfig { enabled: false };
1154 let mut memory = super::MemorySkillConfig {
1155 enabled: true,
1156 mode: super::MemorySkillMode::Stage,
1157 ..super::MemorySkillConfig::default()
1158 };
1159
1160 assert!(memory.effective_enabled(&skills_enabled));
1161 assert!(!memory.effective_enabled(&skills_disabled));
1162
1163 memory.enabled = false;
1164 assert!(!memory.effective_enabled(&skills_enabled));
1165
1166 memory.enabled = true;
1167 memory.mode = super::MemorySkillMode::Off;
1168 assert!(!memory.effective_enabled(&skills_enabled));
1169 }
1170
1171 #[test]
1172 fn missing_config_file_enables_memory_skill() {
1173 let temp = tempfile::tempdir().unwrap();
1174 let config = TruthMirrorConfig::load_or_default(temp.path().join("missing.toml")).unwrap();
1175
1176 assert!(config.skills.enabled);
1177 assert!(config.memory_skill.enabled);
1178 assert!(config.memory_skill.effective_enabled(&config.skills));
1179 }
1180
1181 #[test]
1182 fn empty_config_enables_memory_skill() {
1183 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), "").unwrap();
1184
1185 assert!(config.skills.enabled);
1186 assert!(config.memory_skill.enabled);
1187 assert!(config.memory_skill.effective_enabled(&config.skills));
1188 }
1189
1190 #[test]
1191 fn config_without_skill_sections_enables_memory_skill() {
1192 let contents = r#"
1193[history]
1194window_user = 5
1195"#;
1196 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1197
1198 assert!(config.skills.enabled);
1199 assert!(config.memory_skill.enabled);
1200 assert!(config.memory_skill.effective_enabled(&config.skills));
1201 }
1202
1203 #[test]
1204 fn memory_skill_rejection_precedence_is_legacy_compatible() {
1205 let contents = r#"
1206[memory_skill.signals]
1207rejection_precedence = ["how_to_skill"]
1208"#;
1209
1210 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1211
1212 assert_eq!(
1213 config.memory_skill.signals.rejection_precedence,
1214 vec![super::MemorySkillCandidateKind::HowToSkill]
1215 );
1216 }
1217
1218 #[test]
1219 fn memory_skill_entropy_threshold_must_be_finite() {
1220 let contents = r#"
1221[memory_skill.scan]
1222entropy_threshold = inf
1223"#;
1224
1225 let error =
1226 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1227
1228 assert!(matches!(
1229 error,
1230 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1231 ));
1232 }
1233
1234 #[test]
1235 fn memory_skill_entropy_threshold_must_be_positive() {
1236 let contents = r#"
1237[memory_skill.scan]
1238entropy_threshold = 0.0
1239"#;
1240
1241 let error =
1242 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1243
1244 assert!(matches!(
1245 error,
1246 super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1247 ));
1248 }
1249
1250 #[test]
1251 fn memory_skill_max_skill_bytes_must_be_positive() {
1252 let contents = r#"
1253[memory_skill]
1254max_skill_bytes = 0
1255"#;
1256
1257 let error =
1258 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1259
1260 assert!(matches!(
1261 error,
1262 super::ConfigError::InvalidMemorySkillMaxSkillBytes
1263 ));
1264 }
1265
1266 #[test]
1267 fn memory_skill_secret_detector_timeout_must_be_positive() {
1268 let contents = r#"
1269[memory_skill.scan]
1270secret_detector_timeout_seconds = 0
1271"#;
1272
1273 let error =
1274 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1275
1276 assert!(matches!(
1277 error,
1278 super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
1279 ));
1280 }
1281
1282 #[test]
1283 fn memory_skill_similarity_threshold_must_be_finite_and_bounded() {
1284 for value in ["-0.1", "1.1", "nan", "inf"] {
1285 let contents = format!("[memory_skill.signals]\nsimilarity_threshold = {value}\n");
1286 let error =
1287 TruthMirrorConfig::from_toml_str(Path::new("config.toml"), &contents).unwrap_err();
1288 assert!(matches!(
1289 error,
1290 super::ConfigError::InvalidMemorySkillSimilarityThreshold { .. }
1291 ));
1292 }
1293 }
1294
1295 #[test]
1296 fn empty_gate_lists_fall_back_to_defaults() {
1297 let policy = super::GatesConfig {
1299 fake_markers: Vec::new(),
1300 evidence_patterns: Vec::new(),
1301 marker_ignore_paths: Vec::new(),
1302 }
1303 .to_policy();
1304
1305 assert!(!policy.fake_markers.is_empty());
1306 assert!(!policy.evidence_patterns.is_empty());
1307 assert!(!policy.marker_ignore_paths.is_empty());
1308 }
1309
1310 #[test]
1311 fn memory_skill_blocked_patterns_trim_and_dedupe() {
1312 let config = super::MemorySkillScanConfig {
1313 blocked_patterns: vec![
1314 " API_KEY ".to_owned(),
1315 " custom ".to_owned(),
1316 " ".to_owned(),
1317 "custom".to_owned(),
1318 ],
1319 ..super::MemorySkillScanConfig::default()
1320 };
1321
1322 let patterns = config.effective_blocked_patterns();
1323
1324 assert_eq!(
1325 patterns
1326 .iter()
1327 .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1328 .count(),
1329 1
1330 );
1331 assert_eq!(
1332 patterns
1333 .iter()
1334 .filter(|pattern| *pattern == "custom")
1335 .count(),
1336 1
1337 );
1338 assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1339 }
1340
1341 #[test]
1342 fn pair_keys_are_lowercased() {
1343 let contents = r#"
1344[pairs.CODEX]
1345reviewer = { harness = "claude", model = "claude-opus-4-8" }
1346"#;
1347 let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1348
1349 assert!(config.pair_for("codex").is_some());
1350 assert!(config.pair_for("CoDeX").is_some());
1351 }
1352}