use std::{
collections::BTreeMap,
fs, io,
path::{Path, PathBuf},
};
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize, clap::ValueEnum)]
#[serde(rename_all = "lowercase")]
#[value(rename_all = "lowercase")]
pub enum Effort {
Minimal,
Low,
Medium,
High,
#[default]
Xhigh,
}
impl Effort {
pub fn as_str(self) -> &'static str {
match self {
Effort::Minimal => "minimal",
Effort::Low => "low",
Effort::Medium => "medium",
Effort::High => "high",
Effort::Xhigh => "xhigh",
}
}
pub fn highest() -> Self {
Effort::Xhigh
}
pub fn claude_value(self) -> &'static str {
match self {
Effort::Minimal => "low",
other => other.as_str(),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct HarnessSelection {
pub harness: String,
pub model: String,
#[serde(default)]
pub effort: Effort,
}
impl HarnessSelection {
pub fn new(harness: impl Into<String>, model: impl Into<String>, effort: Effort) -> Self {
Self {
harness: harness.into(),
model: model.into(),
effort,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct AdversarialPair {
pub reviewer: HarnessSelection,
#[serde(default)]
pub arbiter: Option<HarnessSelection>,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct TruthMirrorConfig {
#[serde(default = "default_ledger_dir")]
pub ledger_dir: String,
#[serde(default)]
pub allow_same_model: bool,
#[serde(default = "default_writer")]
pub default_writer: String,
#[serde(default)]
pub pairs: BTreeMap<String, AdversarialPair>,
#[serde(default)]
pub strict: StrictConfig,
#[serde(default)]
pub gates: GatesConfig,
#[serde(default)]
pub ground_truth: GroundTruthConfig,
#[serde(default)]
pub history: HistoryConfig,
#[serde(default)]
pub enforcement: EnforcementConfig,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub review: Option<LegacyReview>,
}
impl Default for TruthMirrorConfig {
fn default() -> Self {
Self {
ledger_dir: default_ledger_dir(),
allow_same_model: false,
default_writer: default_writer(),
pairs: default_pairs(),
strict: StrictConfig::default(),
gates: GatesConfig::default(),
ground_truth: GroundTruthConfig::default(),
history: HistoryConfig::default(),
enforcement: EnforcementConfig::default(),
review: None,
}
}
}
impl TruthMirrorConfig {
pub fn load_for_cli(
explicit_path: Option<&Path>,
state_dir: &Path,
) -> Result<Self, ConfigError> {
let path = explicit_path.map_or_else(|| Self::default_path(state_dir), PathBuf::from);
Self::load_or_default(path)
}
pub fn load_or_default(path: impl Into<PathBuf>) -> Result<Self, ConfigError> {
let path = path.into();
match fs::read_to_string(&path) {
Ok(contents) => Self::from_toml_str(&path, &contents),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(Self::default()),
Err(source) => Err(ConfigError::Read { path, source }),
}
}
pub fn from_toml_str(path: &Path, contents: &str) -> Result<Self, ConfigError> {
let mut config: Self = toml::from_str(contents).map_err(|source| ConfigError::Parse {
path: path.to_path_buf(),
source,
})?;
config.normalize();
config.validate()?;
Ok(config)
}
fn normalize(&mut self) {
let lowered: BTreeMap<String, AdversarialPair> = std::mem::take(&mut self.pairs)
.into_iter()
.map(|(key, value)| (key.trim().to_ascii_lowercase(), value))
.collect();
self.pairs = lowered;
let had_explicit_pairs = !self.pairs.is_empty();
let review = self.review.take();
if !had_explicit_pairs && review.is_some() {
self.pairs = default_pairs();
}
if let Some(review) = review {
let writer = review.watched.harness.trim().to_ascii_lowercase();
let pair = AdversarialPair {
reviewer: HarnessSelection::new(
review.reviewer.harness,
review.reviewer.model,
Effort::highest(),
),
arbiter: None,
};
if had_explicit_pairs {
self.pairs.entry(writer).or_insert(pair);
} else {
self.pairs.insert(writer, pair);
}
}
if self.pairs.is_empty() {
self.pairs = default_pairs();
}
}
fn validate(&self) -> Result<(), ConfigError> {
for (writer, pair) in &self.pairs {
if let Some(arbiter) = &pair.arbiter
&& normalized(&arbiter.model) == normalized(&pair.reviewer.model)
{
return Err(ConfigError::ArbiterNotDistinct {
writer: writer.clone(),
});
}
}
Ok(())
}
pub fn pair_for(&self, writer_harness: &str) -> Option<&AdversarialPair> {
self.pairs.get(&writer_harness.trim().to_ascii_lowercase())
}
pub fn default_path(state_dir: &Path) -> PathBuf {
state_dir.join("config.toml")
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct LegacyReview {
pub watched: LegacyModel,
pub reviewer: LegacyModel,
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
pub struct LegacyModel {
pub harness: String,
pub model: String,
}
#[derive(Clone, Copy, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct StrictConfig {
pub stop_after_lies: u32,
pub stop_after_fuckups: u32,
pub max_passes: u32,
}
impl Default for StrictConfig {
fn default() -> Self {
Self {
stop_after_lies: 1,
stop_after_fuckups: 3,
max_passes: 3,
}
}
}
impl StrictConfig {
pub fn goal_policy(
&self,
lies_override: Option<u32>,
fuckups_override: Option<u32>,
) -> crate::reviewer::StrictGoalPolicy {
crate::reviewer::StrictGoalPolicy {
stop_after_lies: lies_override.unwrap_or(self.stop_after_lies),
stop_after_fuckups: fuckups_override.unwrap_or(self.stop_after_fuckups),
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct GatesConfig {
pub fake_markers: Vec<String>,
pub evidence_patterns: Vec<String>,
pub marker_ignore_paths: Vec<String>,
}
impl Default for GatesConfig {
fn default() -> Self {
Self {
fake_markers: strs(crate::claim::DEFAULT_FAKE_MARKERS),
evidence_patterns: strs(crate::claim::DEFAULT_EVIDENCE_PATTERNS),
marker_ignore_paths: strs(crate::claim::DEFAULT_MARKER_IGNORE_PATHS),
}
}
}
impl GatesConfig {
pub fn to_policy(&self) -> crate::claim::GatePolicy {
crate::claim::GatePolicy {
fake_markers: union_defaults(&self.fake_markers, crate::claim::DEFAULT_FAKE_MARKERS),
evidence_patterns: union_defaults(
&self.evidence_patterns,
crate::claim::DEFAULT_EVIDENCE_PATTERNS,
),
marker_ignore_paths: union_defaults(
&self.marker_ignore_paths,
crate::claim::DEFAULT_MARKER_IGNORE_PATHS,
),
}
}
}
fn strs(values: &[&str]) -> Vec<String> {
values.iter().map(|value| (*value).to_owned()).collect()
}
fn union_defaults(values: &[String], defaults: &[&str]) -> Vec<String> {
let mut out: Vec<String> = strs(defaults);
for value in values {
if !out.iter().any(|existing| existing == value) {
out.push(value.clone());
}
}
out
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct GroundTruthConfig {
pub enabled: bool,
pub file_names: Vec<String>,
pub include_openspec_specs: bool,
pub max_bytes: usize,
}
impl Default for GroundTruthConfig {
fn default() -> Self {
Self {
enabled: true,
file_names: ["TRUTH.md", "AGENTS.md", "CLAUDE.md"]
.iter()
.map(|name| (*name).to_owned())
.collect(),
include_openspec_specs: true,
max_bytes: 20_000,
}
}
}
#[derive(Clone, Debug, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct HistoryConfig {
pub window_user: usize,
pub window_agent: usize,
pub max_bytes: usize,
pub transcript_path: Option<String>,
}
impl Default for HistoryConfig {
fn default() -> Self {
Self {
window_user: 3,
window_agent: 10,
max_bytes: 12_000,
transcript_path: None,
}
}
}
#[derive(Clone, Copy, Debug, Default, Deserialize, Eq, PartialEq, Serialize)]
#[serde(default)]
pub struct EnforcementConfig {
pub block_tools_after_unresolved: u32,
pub block_tools_after_secs: u64,
}
impl EnforcementConfig {
pub fn is_enabled(&self) -> bool {
self.block_tools_after_unresolved > 0 || self.block_tools_after_secs > 0
}
}
#[derive(Debug, Error)]
pub enum ConfigError {
#[error("failed to read config {path}: {source}")]
Read {
path: PathBuf,
#[source]
source: io::Error,
},
#[error("failed to parse config {path}: {source}")]
Parse {
path: PathBuf,
#[source]
source: toml::de::Error,
},
#[error("pair for writer {writer:?} has an arbiter model equal to the reviewer model")]
ArbiterNotDistinct { writer: String },
}
fn default_ledger_dir() -> String {
".truth-mirror".to_owned()
}
fn default_writer() -> String {
"codex".to_owned()
}
fn default_pairs() -> BTreeMap<String, AdversarialPair> {
let mut pairs = BTreeMap::new();
pairs.insert(
"codex".to_owned(),
AdversarialPair {
reviewer: HarnessSelection::new("claude", "claude-opus-4-8", Effort::highest()),
arbiter: Some(HarnessSelection::new(
"pi",
"openai-codex/gpt-5.5",
Effort::highest(),
)),
},
);
pairs.insert(
"claude".to_owned(),
AdversarialPair {
reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
arbiter: Some(HarnessSelection::new(
"pi",
"openai-codex/gpt-5.5",
Effort::highest(),
)),
},
);
pairs.insert(
"pi".to_owned(),
AdversarialPair {
reviewer: HarnessSelection::new("codex", "gpt-5.5", Effort::highest()),
arbiter: Some(HarnessSelection::new(
"claude",
"claude-opus-4-8",
Effort::highest(),
)),
},
);
pairs
}
fn normalized(model: &str) -> String {
model.trim().to_ascii_lowercase()
}
#[cfg(test)]
mod tests {
use std::path::Path;
use super::{Effort, TruthMirrorConfig};
#[test]
fn default_config_has_three_opposed_pairs() {
let config = TruthMirrorConfig::default();
assert_eq!(config.pairs.len(), 3);
let codex = config.pair_for("codex").unwrap();
assert_eq!(codex.reviewer.harness, "claude");
assert_eq!(codex.reviewer.model, "claude-opus-4-8");
assert_eq!(codex.reviewer.effort, Effort::Xhigh);
}
#[test]
fn effort_serializes_lowercase() {
assert_eq!(Effort::Xhigh.as_str(), "xhigh");
assert_eq!(Effort::highest(), Effort::Xhigh);
}
#[test]
fn pairs_config_parses_and_resolves_by_writer() {
let contents = r#"
default_writer = "claude"
[pairs.claude]
reviewer = { harness = "codex", model = "gpt-5.5", effort = "xhigh" }
arbiter = { harness = "pi", model = "openai-codex/gpt-5.5", effort = "high" }
[pairs.codex]
reviewer = { harness = "claude", model = "claude-opus-4-8" }
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let claude_pair = config.pair_for("claude").unwrap();
assert_eq!(claude_pair.reviewer.harness, "codex");
assert_eq!(claude_pair.reviewer.effort, Effort::Xhigh);
assert_eq!(claude_pair.arbiter.as_ref().unwrap().effort, Effort::High);
let codex_pair = config.pair_for("codex").unwrap();
assert_eq!(codex_pair.reviewer.effort, Effort::Xhigh);
}
#[test]
fn legacy_review_block_overrides_default_pair() {
let contents = r#"
ledger_dir = ".truth-mirror"
[review.watched]
harness = "codex"
model = "gpt-5.5"
[review.reviewer]
harness = "gemini"
model = "gemini-3-pro"
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let pair = config.pair_for("codex").unwrap();
assert_eq!(pair.reviewer.harness, "gemini");
assert_eq!(pair.reviewer.model, "gemini-3-pro");
assert!(config.pair_for("claude").is_some());
assert!(config.pair_for("pi").is_some());
}
#[test]
fn explicit_pairs_win_over_legacy_review() {
let contents = r#"
[pairs.codex]
reviewer = { harness = "claude", model = "explicit-model" }
[review.watched]
harness = "codex"
model = "gpt-5.5"
[review.reviewer]
harness = "gemini"
model = "legacy-model"
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let pair = config.pair_for("codex").unwrap();
assert_eq!(pair.reviewer.harness, "claude");
assert_eq!(pair.reviewer.model, "explicit-model");
}
#[test]
fn explicit_pairs_win_case_insensitively_over_legacy() {
let contents = r#"
[pairs.CODEX]
reviewer = { harness = "claude", model = "explicit-model" }
[review.watched]
harness = "codex"
model = "gpt-5.5"
[review.reviewer]
harness = "gemini"
model = "legacy-model"
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let pair = config.pair_for("codex").unwrap();
assert_eq!(pair.reviewer.model, "explicit-model");
assert_eq!(config.pairs.len(), 1);
}
#[test]
fn arbiter_equal_to_reviewer_model_is_rejected() {
let contents = r#"
[pairs.codex]
reviewer = { harness = "claude", model = "same-model" }
arbiter = { harness = "pi", model = "same-model" }
"#;
let error =
TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap_err();
assert!(matches!(
error,
super::ConfigError::ArbiterNotDistinct { .. }
));
}
#[test]
fn missing_config_loads_default() {
let config = TruthMirrorConfig::load_or_default("missing-config.toml").unwrap();
assert_eq!(config.pairs.len(), 3);
assert!(config.ground_truth.enabled);
assert_eq!(config.history.window_user, 3);
assert!(!config.enforcement.is_enabled());
}
#[test]
fn gates_config_parses_and_builds_policy() {
let contents = r#"
[pairs.codex]
reviewer = { harness = "claude", model = "claude-opus-4-8" }
[gates]
fake_markers = ["pretend-pass"]
evidence_patterns = ["jira:"]
marker_ignore_paths = [".md", "vendor/"]
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
let default_marker = ["mock", "as", "real"].join("-");
let policy = config.gates.to_policy();
assert!(policy.fake_markers.iter().any(|m| m == "pretend-pass"));
assert!(policy.fake_markers.contains(&default_marker));
assert!(policy.evidence_patterns.iter().any(|p| p == "jira:"));
assert!(policy.evidence_patterns.iter().any(|p| p == "tests:"));
assert!(policy.marker_ignore_paths.iter().any(|p| p == "vendor/"));
assert!(policy.marker_ignore_paths.iter().any(|p| p == "openspec/"));
}
#[test]
fn empty_gate_lists_fall_back_to_defaults() {
let policy = super::GatesConfig {
fake_markers: Vec::new(),
evidence_patterns: Vec::new(),
marker_ignore_paths: Vec::new(),
}
.to_policy();
assert!(!policy.fake_markers.is_empty());
assert!(!policy.evidence_patterns.is_empty());
assert!(!policy.marker_ignore_paths.is_empty());
}
#[test]
fn pair_keys_are_lowercased() {
let contents = r#"
[pairs.CODEX]
reviewer = { harness = "claude", model = "claude-opus-4-8" }
"#;
let config = TruthMirrorConfig::from_toml_str(Path::new("config.toml"), contents).unwrap();
assert!(config.pair_for("codex").is_some());
assert!(config.pair_for("CoDeX").is_some());
}
}