Skip to main content

truth_mirror/
config.rs

1//! truth-mirror configuration: adversarial pairs, reasoning effort, gates,
2//! ground-truth, trajectory, and enforcement.
3
4use 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/// Reasoning effort (`C`). Highest is `Xhigh`.
33#[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    /// The highest supported effort — the default reviewer aggressiveness.
57    pub fn highest() -> Self {
58        Effort::Xhigh
59    }
60
61    /// Value accepted by Claude's `--effort`. Verified: claude takes
62    /// `low|medium|high|xhigh|max` and has no `minimal`, so `Minimal` clamps to
63    /// `low`. (Codex and Pi both accept `minimal`.)
64    pub fn claude_value(self) -> &'static str {
65        match self {
66            Effort::Minimal => "low",
67            other => other.as_str(),
68        }
69    }
70}
71
72/// A concrete reviewer/arbiter selection: harness + model + reasoning effort.
73#[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/// The opposed reviewer (and optional second-pass arbiter) for one writer harness.
92#[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    /// Writer harness assumed when none is provided on the CLI or commit trailer.
106    #[serde(default = "default_writer")]
107    pub default_writer: String,
108    /// Adversarial pairs keyed by writer harness (lowercase). Empty in the parsed
109    /// form when `[pairs]` is absent; `normalize` fills defaults / folds legacy.
110    #[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    /// Legacy `[review]` block, folded into `pairs` on load for back-compat.
127    #[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    /// Resolve the effective `pairs`: explicit `[pairs]` win; a legacy `[review]`
179    /// block folds in as an OVERRIDE for its writer (on top of defaults so other
180    /// writers still resolve); with neither, all writers get default pairs.
181    fn normalize(&mut self) {
182        // Lowercase explicit pair keys FIRST so every comparison below is
183        // case-insensitive (matching `pair_for`). Otherwise a `[pairs.CODEX]` could
184        // be clobbered by a legacy `[review]` folded under `codex`.
185        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            // Legacy-only config: seed defaults so non-legacy writers still resolve.
196            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                // Explicit `[pairs]` win: legacy only fills a writer they omit.
211                self.pairs.entry(writer).or_insert(pair);
212            } else {
213                // Legacy-only config: override the seeded default for this writer.
214                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    /// The adversarial pair for a writer harness (case-insensitive).
250    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/// Legacy single watched/reviewer pair (pre-adversarial-pairs config).
260#[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/// Strict goal-loop thresholds. `N == 0` disables that stop condition.
273#[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/// Deterministic-gate configuration: banned diff sentinels, evidence patterns,
305/// and diff paths excluded from marker scanning.
306#[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    /// The resolved gate policy: config values plus built-in defaults for any
326    /// list left empty, so an empty config never silently disables a gate.
327    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
346/// Built-in defaults ALWAYS apply; configured values are additive. A repo can add
347/// its own banned tokens or evidence patterns but cannot silently disable the
348/// deterministic anti-lie defaults by setting a shorter list.
349fn 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/// Ground-truth constraint loading.
360#[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/// Conversation-trajectory window.
384#[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    /// Optional repo-relative JSONL transcript (`{role,text}` per line). When
391    /// unset or missing, recent commits are used as the trajectory proxy.
392    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/// Enforcement escalation thresholds. `0` disables a condition.
407#[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, Deserialize, Eq, PartialEq, Serialize)]
421#[serde(default)]
422pub struct SkillsConfig {
423    pub enabled: bool,
424}
425
426impl Default for SkillsConfig {
427    fn default() -> Self {
428        Self { enabled: true }
429    }
430}
431
432#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
433#[serde(rename_all = "lowercase")]
434pub enum MemorySkillMode {
435    Off,
436    Suggest,
437    #[default]
438    Stage,
439}
440
441#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
442#[serde(rename_all = "snake_case")]
443pub enum MemorySkillCandidateKind {
444    HowToSkill,
445    AntiPatternSkill,
446    RemediationSkill,
447}
448
449impl MemorySkillCandidateKind {
450    pub fn as_str(self) -> &'static str {
451        match self {
452            Self::HowToSkill => "how_to_skill",
453            Self::AntiPatternSkill => "anti_pattern_skill",
454            Self::RemediationSkill => "remediation_skill",
455        }
456    }
457}
458
459impl std::fmt::Display for MemorySkillCandidateKind {
460    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
461        formatter.write_str(self.as_str())
462    }
463}
464
465#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
466#[serde(default)]
467pub struct MemorySkillConfig {
468    pub enabled: bool,
469    pub mode: MemorySkillMode,
470    pub candidate_dir: String,
471    pub approved_dir: String,
472    pub approved_slug_prefix: String,
473    pub toolbox_delivery_registry: String,
474    pub allow_global_writes: bool,
475    pub require_claim_evidence: bool,
476    pub max_candidates_per_commit: usize,
477    pub max_skill_bytes: usize,
478    pub pre_push_blocks_pending: bool,
479    /// Historical config key: when true, `memory_skill::scan_text` rejects the
480    /// whole candidate on secret matches; it does not redact matched text in
481    /// place. Scanning is fully disabled only when this is false and
482    /// `memory_skill.scan.secret_detection = "off"`.
483    pub redact_secrets: bool,
484    pub signals: MemorySkillSignalsConfig,
485    pub review: MemorySkillReviewConfig,
486    pub scan: MemorySkillScanConfig,
487}
488
489impl Default for MemorySkillConfig {
490    fn default() -> Self {
491        Self {
492            enabled: true,
493            mode: MemorySkillMode::Stage,
494            candidate_dir: format!("{DEFAULT_STATE_DIR}/skills/candidates"),
495            approved_dir: ".agents/skills".to_owned(),
496            approved_slug_prefix: "generated-".to_owned(),
497            toolbox_delivery_registry: String::new(),
498            allow_global_writes: false,
499            require_claim_evidence: true,
500            max_candidates_per_commit: 1,
501            max_skill_bytes: 12_000,
502            pre_push_blocks_pending: false,
503            redact_secrets: true,
504            signals: MemorySkillSignalsConfig::default(),
505            review: MemorySkillReviewConfig::default(),
506            scan: MemorySkillScanConfig::default(),
507        }
508    }
509}
510
511impl MemorySkillConfig {
512    pub fn effective_enabled(&self, skills: &SkillsConfig) -> bool {
513        skills.enabled && self.enabled && self.mode != MemorySkillMode::Off
514    }
515}
516
517#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
518#[serde(default)]
519pub struct MemorySkillSignalsConfig {
520    pub min_occurrences: usize,
521    pub require_reusable_procedure: bool,
522    pub capture_passes_as_how_to: bool,
523    pub capture_rejections_as_remediation: bool,
524    pub capture_rejections_as_antipattern: bool,
525    pub rejection_precedence: Vec<MemorySkillCandidateKind>,
526}
527
528impl Default for MemorySkillSignalsConfig {
529    fn default() -> Self {
530        Self {
531            min_occurrences: 2,
532            require_reusable_procedure: true,
533            capture_passes_as_how_to: true,
534            capture_rejections_as_remediation: true,
535            capture_rejections_as_antipattern: true,
536            rejection_precedence: vec![
537                MemorySkillCandidateKind::AntiPatternSkill,
538                MemorySkillCandidateKind::RemediationSkill,
539            ],
540        }
541    }
542}
543
544#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
545#[serde(default)]
546pub struct MemorySkillReviewConfig {
547    pub require_adversarial_review: bool,
548    pub require_reviewed_ledger_entry: bool,
549    pub require_pass_for_how_to: bool,
550    pub require_structured_findings_checked: bool,
551    pub reject_without_ledger_entry: bool,
552}
553
554impl Default for MemorySkillReviewConfig {
555    fn default() -> Self {
556        Self {
557            require_adversarial_review: true,
558            require_reviewed_ledger_entry: true,
559            require_pass_for_how_to: true,
560            require_structured_findings_checked: true,
561            reject_without_ledger_entry: true,
562        }
563    }
564}
565
566#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
567#[serde(default)]
568pub struct MemorySkillScanConfig {
569    pub secret_detection: SecretDetectionMode,
570    /// Optional scanner command. A non-zero exit is treated as a detected
571    /// finding; best-effort mode only downgrades operational failures.
572    pub secret_detector: String,
573    pub allow_secret_detector_command: bool,
574    pub secret_detector_timeout_seconds: u64,
575    pub entropy_threshold: f64,
576    pub blocked_patterns: Vec<String>,
577    pub blocked_patterns_case_insensitive: bool,
578}
579
580impl Default for MemorySkillScanConfig {
581    fn default() -> Self {
582        Self {
583            secret_detection: SecretDetectionMode::Required,
584            secret_detector: String::new(),
585            allow_secret_detector_command: false,
586            secret_detector_timeout_seconds: 10,
587            entropy_threshold: 4.5,
588            blocked_patterns: Vec::new(),
589            blocked_patterns_case_insensitive: true,
590        }
591    }
592}
593
594impl MemorySkillScanConfig {
595    pub fn effective_blocked_patterns(&self) -> Vec<String> {
596        let mut patterns = Vec::new();
597        for pattern in DEFAULT_MEMORY_SKILL_BLOCKED_PATTERNS {
598            push_unique_pattern(
599                &mut patterns,
600                pattern,
601                self.blocked_patterns_case_insensitive,
602            );
603        }
604        for pattern in &self.blocked_patterns {
605            push_unique_pattern(
606                &mut patterns,
607                pattern,
608                self.blocked_patterns_case_insensitive,
609            );
610        }
611        patterns
612    }
613}
614
615fn push_unique_pattern(patterns: &mut Vec<String>, pattern: &str, case_insensitive: bool) {
616    let pattern = pattern.trim();
617    if pattern.is_empty()
618        || patterns.iter().any(|existing| {
619            if case_insensitive {
620                existing.eq_ignore_ascii_case(pattern)
621            } else {
622                existing == pattern
623            }
624        })
625    {
626        return;
627    }
628    patterns.push(pattern.to_owned());
629}
630
631#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
632#[serde(rename_all = "kebab-case")]
633pub enum SecretDetectionMode {
634    #[default]
635    Required,
636    BestEffort,
637    Off,
638}
639
640#[derive(Debug, Error)]
641pub enum ConfigError {
642    #[error("failed to read config {path}: {source}")]
643    Read {
644        path: PathBuf,
645        #[source]
646        source: io::Error,
647    },
648    #[error("failed to parse config {path}: {source}")]
649    Parse {
650        path: PathBuf,
651        #[source]
652        source: toml::de::Error,
653    },
654    #[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
655    ArbiterNotDistinct { writer: String },
656    #[error("memory_skill.scan.entropy_threshold must be finite and greater than 0.0, got {value}")]
657    InvalidMemorySkillEntropyThreshold { value: f64 },
658    #[error("memory_skill.max_skill_bytes must be greater than 0")]
659    InvalidMemorySkillMaxSkillBytes,
660    #[error("memory_skill.scan.secret_detector_timeout_seconds must be greater than 0")]
661    InvalidMemorySkillSecretDetectorTimeout,
662}
663
664fn default_ledger_dir() -> String {
665    DEFAULT_STATE_DIR.to_owned()
666}
667
668fn default_writer() -> String {
669    "codex".to_owned()
670}
671
672/// Sensible opposed pairs so a repo with no `[pairs]` still reviews adversarially.
673fn default_pairs() -> BTreeMap<String, AdversarialPair> {
674    let mut pairs = BTreeMap::new();
675    pairs.insert(
676        "codex".to_owned(),
677        AdversarialPair {
678            reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
679            arbiter: Some(HarnessSelection::new(
680                "pi",
681                "openai-codex/gpt-5.5",
682                Effort::highest(),
683            )),
684        },
685    );
686    pairs.insert(
687        "claude".to_owned(),
688        AdversarialPair {
689            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
690            // Arbiter must be a third distinct model. The reviewer is codex/gpt-5.5;
691            // using pi/openai-codex/gpt-5.5 as arbiter would be the SAME underlying
692            // model after provider-prefix stripping, violating the distinct-model rule.
693            // Gemini (gemini-2.5-pro) is a genuinely different family and provider,
694            // and InvocationPlan::for_harness supports ReviewerHarness::Gemini.
695            arbiter: Some(HarnessSelection::new(
696                "gemini",
697                "gemini-2.5-pro",
698                Effort::highest(),
699            )),
700        },
701    );
702    pairs.insert(
703        "pi".to_owned(),
704        AdversarialPair {
705            reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
706            arbiter: Some(HarnessSelection::new(
707                "claude",
708                "claude-opus-4-8",
709                Effort::highest(),
710            )),
711        },
712    );
713    // Grok is a watched agent surface, not a reviewer harness. Oppose with Claude
714    // review + Codex arbiter so model/provider families stay distinct by default.
715    pairs.insert(
716        "grok".to_owned(),
717        AdversarialPair {
718            reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
719            arbiter: Some(HarnessSelection::new("codex", "gpt-5.5", Effort::highest())),
720        },
721    );
722    pairs
723}
724
725/// Normalise a model identifier for opposition comparisons.
726///
727/// Strips the provider prefix (everything up to and including the last `/`)
728/// so `openai-codex/gpt-5.5` compares equal to `gpt-5.5`. Keeps config
729/// validation consistent with the runtime check in `reviewer::normalized_model`.
730/// The prefix is only stripped when it leaves a non-empty suffix.
731pub(crate) fn normalized_model(model: &str) -> String {
732    let model = model.trim();
733    let short = model
734        .rsplit_once('/')
735        .and_then(|(_, suffix)| {
736            let s = suffix.trim();
737            if s.is_empty() { None } else { Some(s) }
738        })
739        .unwrap_or(model);
740    short.to_ascii_lowercase()
741}
742
743#[cfg(test)]
744mod tests {
745    use std::path::Path;
746
747    use super::{Effort, TruthMirrorConfig};
748
749    #[test]
750    fn default_config_has_four_opposed_pairs() {
751        let config = TruthMirrorConfig::default();
752
753        assert_eq!(config.pairs.len(), 4);
754        assert_eq!(config.ledger_dir, ".truth");
755        assert!(config.skills.enabled);
756        assert!(config.memory_skill.enabled);
757        assert!(config.memory_skill.effective_enabled(&config.skills));
758        assert_eq!(config.memory_skill.mode, super::MemorySkillMode::Stage);
759        let codex = config.pair_for("codex").unwrap();
760        assert_eq!(codex.reviewer.harness, "claude");
761        assert_eq!(codex.reviewer.model, "claude-opus-4-8");
762        assert_eq!(codex.reviewer.effort, Effort::Xhigh);
763        let grok = config.pair_for("grok").unwrap();
764        assert_eq!(grok.reviewer.harness, "claude");
765        assert_eq!(grok.reviewer.model, "claude-opus-4-8");
766    }
767
768    #[test]
769    fn effort_serializes_lowercase() {
770        assert_eq!(Effort::Xhigh.as_str(), "xhigh");
771        assert_eq!(Effort::highest(), Effort::Xhigh);
772    }
773
774    #[test]
775    fn pairs_config_parses_and_resolves_by_writer() {
776        // Arbiter must be distinct from reviewer after provider-prefix stripping:
777        // gemini/gemini-2.5-pro normalizes to "gemini-2.5-pro", which != "gpt-5.5".
778        let contents = r#"
779default_writer = "claude"
780
781[pairs.claude]
782reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
783arbiter  = { harness = "gemini", model = "gemini-2.5-pro", effort = "high" }
784
785[pairs.codex]
786reviewer = { harness = "claude", model = "claude-opus-4-8" }
787"#;
788        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
789
790        let claude_pair = config.pair_for("claude").unwrap();
791        assert_eq!(claude_pair.reviewer.harness, "codex");
792        assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
793        assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
794
795        // Omitted effort defaults to highest.
796        let codex_pair = config.pair_for("codex").unwrap();
797        assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
798    }
799
800    #[test]
801    fn arbiter_same_as_reviewer_after_prefix_strip_is_rejected() {
802        // openai-codex/gpt-5.5 normalizes to gpt-5.5, same as the reviewer model.
803        // This was the bug in the original claude default pair (B1/B2 remediation).
804        let contents = r#"
805[pairs.claude]
806reviewer = { harness = "codex", model = "gpt-5.5" }
807arbiter  = { harness = "pi", model = "openai-codex/gpt-5.5" }
808"#;
809        let result = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents);
810        assert!(result.is_err());
811        let err = result.unwrap_err().to_string();
812        assert!(
813            err.contains("arbiter"),
814            "expected arbiter error, got: {err}"
815        );
816    }
817
818    #[test]
819    fn default_claude_pair_arbiter_is_distinct_from_reviewer() {
820        // B1 fix: the claude default pair used to have arbiter=pi/openai-codex/gpt-5.5
821        // and reviewer=codex/gpt-5.5. After provider-prefix stripping both normalize
822        // to "gpt-5.5", which is the same model. The fix changed the arbiter to
823        // gemini/gemini-2.5-pro. This test ensures the default config loads without
824        // ArbiterNotDistinct and has a genuinely distinct arbiter.
825        let config = TruthMirrorConfig::default();
826        let pair = config.pair_for("claude").expect("claude pair must exist");
827        let arbiter = pair.arbiter.as_ref().expect("claude arbiter must be set");
828        assert_eq!(
829            arbiter.harness, "gemini",
830            "claude arbiter harness must be gemini"
831        );
832        assert_ne!(
833            super::normalized_model(&arbiter.model),
834            super::normalized_model(&pair.reviewer.model),
835            "claude arbiter must be distinct from reviewer after normalization"
836        );
837    }
838
839    #[test]
840    fn normalized_strips_provider_prefix_for_comparison() {
841        // B2: openai-codex/gpt-5.5 and gpt-5.5 should normalize to the same token.
842        assert_eq!(
843            super::normalized_model("openai-codex/gpt-5.5"),
844            super::normalized_model("gpt-5.5")
845        );
846        // Prefix-only values (bare "/") keep their identity to avoid false collisions.
847        assert_ne!(super::normalized_model("/"), super::normalized_model(""));
848    }
849
850    #[test]
851    fn legacy_review_block_overrides_default_pair() {
852        // Reviewer distinct from the built-in codex default (claude/opus-4-8) so
853        // the test proves the legacy block actually overrides the default rather
854        // than coincidentally matching it.
855        let contents = r#"
856ledger_dir = ".truth-mirror"
857
858[review.watched]
859harness = "codex"
860model = "gpt-5.5"
861
862[review.reviewer]
863harness = "gemini"
864model = "gemini-3-pro"
865"#;
866        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
867
868        let pair = config.pair_for("codex").unwrap();
869        assert_eq!(pair.reviewer.harness, "gemini");
870        assert_eq!(pair.reviewer.model, "gemini-3-pro");
871        // Other writers still resolve from defaults.
872        assert!(config.pair_for("claude").is_some());
873        assert!(config.pair_for("pi").is_some());
874        assert!(config.pair_for("grok").is_some());
875    }
876
877    #[test]
878    fn explicit_pairs_win_over_legacy_review() {
879        // A mixed migration config must not let legacy [review] clobber a modern
880        // explicit [pairs.<writer>] entry.
881        let contents = r#"
882[pairs.codex]
883reviewer = { harness = "claude", model = "explicit-model" }
884
885[review.watched]
886harness = "codex"
887model = "gpt-5.5"
888
889[review.reviewer]
890harness = "gemini"
891model = "legacy-model"
892"#;
893        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
894
895        let pair = config.pair_for("codex").unwrap();
896        assert_eq!(pair.reviewer.harness, "claude");
897        assert_eq!(pair.reviewer.model, "explicit-model");
898    }
899
900    #[test]
901    fn explicit_pairs_win_case_insensitively_over_legacy() {
902        // `[pairs.CODEX]` (uppercase) must not be clobbered by a legacy [review]
903        // folded under lowercase `codex`.
904        let contents = r#"
905[pairs.CODEX]
906reviewer = { harness = "claude", model = "explicit-model" }
907
908[review.watched]
909harness = "codex"
910model = "gpt-5.5"
911
912[review.reviewer]
913harness = "gemini"
914model = "legacy-model"
915"#;
916        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
917
918        let pair = config.pair_for("codex").unwrap();
919        assert_eq!(pair.reviewer.model, "explicit-model");
920        // No duplicate CODEX/codex entries.
921        assert_eq!(config.pairs.len(), 1);
922    }
923
924    #[test]
925    fn arbiter_equal_to_reviewer_model_is_rejected() {
926        let contents = r#"
927[pairs.codex]
928reviewer = { harness = "claude", model = "same-model" }
929arbiter  = { harness = "pi", model = "same-model" }
930"#;
931        let error =
932            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
933
934        assert!(matches!(
935            error,
936            super::ConfigError::ArbiterNotDistinct { .. }
937        ));
938    }
939
940    #[test]
941    fn missing_config_loads_default() {
942        let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
943
944        assert_eq!(config.pairs.len(), 4);
945        assert!(config.ground_truth.enabled);
946        assert_eq!(config.history.window_user, 3);
947        assert!(!config.enforcement.is_enabled());
948    }
949
950    #[test]
951    fn gates_config_parses_and_builds_policy() {
952        let contents = r#"
953[pairs.codex]
954reviewer = { harness = "claude", model = "claude-opus-4-8" }
955
956[gates]
957fake_markers = ["pretend-pass"]
958evidence_patterns = ["jira:"]
959marker_ignore_paths = [".md", "vendor/"]
960"#;
961        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
962
963        // Config values are ADDITIVE: built-in defaults always apply, plus the
964        // repo's custom entries. A repo can add but not silently disable defaults.
965        // The default marker literal is built at runtime so it never appears as an
966        // added line in this file (truth-mirror's own gate would flag it).
967        let default_marker = ["mock", "as", "real"].join("-");
968        let policy = config.gates.to_policy();
969        assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
970        assert!(policy.fake_markers.contains(&default_marker));
971        assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
972        assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
973        assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
974        assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
975    }
976
977    #[test]
978    fn memory_skill_effective_enabled_requires_parent_self_and_active_mode() {
979        let skills_enabled = super::SkillsConfig { enabled: true };
980        let skills_disabled = super::SkillsConfig { enabled: false };
981        let mut memory = super::MemorySkillConfig {
982            enabled: true,
983            mode: super::MemorySkillMode::Stage,
984            ..super::MemorySkillConfig::default()
985        };
986
987        assert!(memory.effective_enabled(&skills_enabled));
988        assert!(!memory.effective_enabled(&skills_disabled));
989
990        memory.enabled = false;
991        assert!(!memory.effective_enabled(&skills_enabled));
992
993        memory.enabled = true;
994        memory.mode = super::MemorySkillMode::Off;
995        assert!(!memory.effective_enabled(&skills_enabled));
996    }
997
998    #[test]
999    fn missing_config_file_enables_memory_skill() {
1000        let temp = tempfile::tempdir().unwrap();
1001        let config = TruthMirrorConfig::load_or_default(temp.path().join("missing.toml")).unwrap();
1002
1003        assert!(config.skills.enabled);
1004        assert!(config.memory_skill.enabled);
1005        assert!(config.memory_skill.effective_enabled(&config.skills));
1006    }
1007
1008    #[test]
1009    fn empty_config_enables_memory_skill() {
1010        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), "").unwrap();
1011
1012        assert!(config.skills.enabled);
1013        assert!(config.memory_skill.enabled);
1014        assert!(config.memory_skill.effective_enabled(&config.skills));
1015    }
1016
1017    #[test]
1018    fn config_without_skill_sections_enables_memory_skill() {
1019        let contents = r#"
1020[history]
1021window_user = 5
1022"#;
1023        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1024
1025        assert!(config.skills.enabled);
1026        assert!(config.memory_skill.enabled);
1027        assert!(config.memory_skill.effective_enabled(&config.skills));
1028    }
1029
1030    #[test]
1031    fn memory_skill_rejection_precedence_is_legacy_compatible() {
1032        let contents = r#"
1033[memory_skill.signals]
1034rejection_precedence = ["how_to_skill"]
1035"#;
1036
1037        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1038
1039        assert_eq!(
1040            config.memory_skill.signals.rejection_precedence,
1041            vec![super::MemorySkillCandidateKind::HowToSkill]
1042        );
1043    }
1044
1045    #[test]
1046    fn memory_skill_entropy_threshold_must_be_finite() {
1047        let contents = r#"
1048[memory_skill.scan]
1049entropy_threshold = inf
1050"#;
1051
1052        let error =
1053            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1054
1055        assert!(matches!(
1056            error,
1057            super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1058        ));
1059    }
1060
1061    #[test]
1062    fn memory_skill_entropy_threshold_must_be_positive() {
1063        let contents = r#"
1064[memory_skill.scan]
1065entropy_threshold = 0.0
1066"#;
1067
1068        let error =
1069            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1070
1071        assert!(matches!(
1072            error,
1073            super::ConfigError::InvalidMemorySkillEntropyThreshold { .. }
1074        ));
1075    }
1076
1077    #[test]
1078    fn memory_skill_max_skill_bytes_must_be_positive() {
1079        let contents = r#"
1080[memory_skill]
1081max_skill_bytes = 0
1082"#;
1083
1084        let error =
1085            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1086
1087        assert!(matches!(
1088            error,
1089            super::ConfigError::InvalidMemorySkillMaxSkillBytes
1090        ));
1091    }
1092
1093    #[test]
1094    fn memory_skill_secret_detector_timeout_must_be_positive() {
1095        let contents = r#"
1096[memory_skill.scan]
1097secret_detector_timeout_seconds = 0
1098"#;
1099
1100        let error =
1101            TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
1102
1103        assert!(matches!(
1104            error,
1105            super::ConfigError::InvalidMemorySkillSecretDetectorTimeout
1106        ));
1107    }
1108
1109    #[test]
1110    fn empty_gate_lists_fall_back_to_defaults() {
1111        // An explicitly empty list must not silently disable a gate.
1112        let policy = super::GatesConfig {
1113            fake_markers: Vec::new(),
1114            evidence_patterns: Vec::new(),
1115            marker_ignore_paths: Vec::new(),
1116        }
1117        .to_policy();
1118
1119        assert!(!policy.fake_markers.is_empty());
1120        assert!(!policy.evidence_patterns.is_empty());
1121        assert!(!policy.marker_ignore_paths.is_empty());
1122    }
1123
1124    #[test]
1125    fn memory_skill_blocked_patterns_trim_and_dedupe() {
1126        let config = super::MemorySkillScanConfig {
1127            blocked_patterns: vec![
1128                " API_KEY ".to_owned(),
1129                " custom ".to_owned(),
1130                " ".to_owned(),
1131                "custom".to_owned(),
1132            ],
1133            ..super::MemorySkillScanConfig::default()
1134        };
1135
1136        let patterns = config.effective_blocked_patterns();
1137
1138        assert_eq!(
1139            patterns
1140                .iter()
1141                .filter(|pattern| pattern.eq_ignore_ascii_case("api_key"))
1142                .count(),
1143            1
1144        );
1145        assert_eq!(
1146            patterns
1147                .iter()
1148                .filter(|pattern| *pattern == "custom")
1149                .count(),
1150            1
1151        );
1152        assert!(patterns.iter().all(|pattern| pattern.trim() == pattern));
1153    }
1154
1155    #[test]
1156    fn pair_keys_are_lowercased() {
1157        let contents = r#"
1158[pairs.CODEX]
1159reviewer = { harness = "claude", model = "claude-opus-4-8" }
1160"#;
1161        let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
1162
1163        assert!(config.pair_for("codex").is_some());
1164        assert!(config.pair_for("CoDeX").is_some());
1165    }
1166}