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, 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    /// Historical config key: when true, `memory_skill::scan_text` rejects the
474    /// whole candidate on secret matches; it does not redact matched text in
475    /// place. Scanning is fully disabled only when this is false and
476    /// `memory_skill.scan.secret_detection = "off"`.
477    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    /// Optional scanner command. A non-zero exit is treated as a detected
565    /// finding; best-effort mode only downgrades operational failures.
566    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
666/// Sensible opposed pairs so a repo with no `[pairs]` still reviews adversarially.
667fn 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 must be a third distinct model. The reviewer is codex/gpt-5.5;
685            // using pi/openai-codex/gpt-5.5 as arbiter would be the SAME underlying
686            // model after provider-prefix stripping, violating the distinct-model rule.
687            // Gemini (gemini-2.5-pro) is a genuinely different family and provider,
688            // and InvocationPlan::for_harness supports ReviewerHarness::Gemini.
689            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    // Grok is a watched agent surface, not a reviewer harness. Oppose with Claude
708    // review + Codex arbiter so model/provider families stay distinct by default.
709    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
719/// Normalise a model identifier for opposition comparisons.
720///
721/// Strips the provider prefix (everything up to and including the last `/`)
722/// so `openai-codex/gpt-5.5` compares equal to `gpt-5.5`. Keeps config
723/// validation consistent with the runtime check in `reviewer::normalized_model`.
724/// The prefix is only stripped when it leaves a non-empty suffix.
725pub(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        // Arbiter must be distinct from reviewer after provider-prefix stripping:
770        // gemini/gemini-2.5-pro normalizes to "gemini-2.5-pro", which != "gpt-5.5".
771        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        // Omitted effort defaults to highest.
789        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        // openai-codex/gpt-5.5 normalizes to gpt-5.5, same as the reviewer model.
796        // This was the bug in the original claude default pair (B1/B2 remediation).
797        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        // B1 fix: the claude default pair used to have arbiter=pi/openai-codex/gpt-5.5
814        // and reviewer=codex/gpt-5.5. After provider-prefix stripping both normalize
815        // to "gpt-5.5", which is the same model. The fix changed the arbiter to
816        // gemini/gemini-2.5-pro. This test ensures the default config loads without
817        // ArbiterNotDistinct and has a genuinely distinct arbiter.
818        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        // B2: openai-codex/gpt-5.5 and gpt-5.5 should normalize to the same token.
835        assert_eq!(
836            super::normalized_model("openai-codex/gpt-5.5"),
837            super::normalized_model("gpt-5.5")
838        );
839        // Prefix-only values (bare "/") keep their identity to avoid false collisions.
840        assert_ne!(super::normalized_model("/"), super::normalized_model(""));
841    }
842
843    #[test]
844    fn legacy_review_block_overrides_default_pair() {
845        // Reviewer distinct from the built-in codex default (claude/opus-4-8) so
846        // the test proves the legacy block actually overrides the default rather
847        // than coincidentally matching it.
848        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        // Other writers still resolve from defaults.
865        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        // A mixed migration config must not let legacy [review] clobber a modern
873        // explicit [pairs.<writer>] entry.
874        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        // `[pairs.CODEX]` (uppercase) must not be clobbered by a legacy [review]
896        // folded under lowercase `codex`.
897        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        // No duplicate CODEX/codex entries.
914        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        // Config values are ADDITIVE: built-in defaults always apply, plus the
957        // repo's custom entries. A repo can add but not silently disable defaults.
958        // The default marker literal is built at runtime so it never appears as an
959        // added line in this file (truth-mirror's own gate would flag it).
960        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        // An explicitly empty list must not silently disable a gate.
1073        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}