truth-mirror 0.8.0

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! Agent reinjection adapters for unresolved findings.

use std::{env, ffi::OsStr, path::Path, process::ExitCode};

use anyhow::Result;

use crate::{
    cli::{Agent, ReinjectArgs},
    ledger::{LedgerEntry, LedgerStore},
};

/// Grok-only env vars the Grok Build hook runner injects on every hook process.
/// Used to gate Claude reinjection when Grok loads `.claude/settings.json` via
/// harness compatibility (so native `--agent grok` owns reinjection under Grok).
const GROK_SESSION_ID_ENV: &str = "GROK_SESSION_ID";
const GROK_HOOK_EVENT_ENV: &str = "GROK_HOOK_EVENT";

pub fn run(
    args: ReinjectArgs,
    state_dir: &Path,
    config: &crate::config::TruthMirrorConfig,
) -> Result<ExitCode> {
    // Claude surface hooks can fire under Grok via [compat.claude] scanning.
    // Only suppress Claude-labeled reinjection when the native Grok skill is
    // installed (the agent should use `--agent grok` / the skill). If Grok is
    // detected without that skill, warn and still emit Claude-format output so
    // findings are not silently dropped.
    if should_skip_claude_reinject_under_grok(args.agent, under_grok_from_env()) {
        let roots = grok_skill_search_roots(state_dir);
        if roots
            .iter()
            .any(|root| crate::surface::grok_skill_installed(root))
        {
            tracing::debug!(
                agent = ?args.agent,
                "skipping Claude reinjection: Grok env + owned Grok skill installed"
            );
            return Ok(ExitCode::SUCCESS);
        }
        eprintln!(
            "{} under Grok without owned {} — emitting Claude reinject output; install `truth-mirror install-hooks --grok` for the Grok skill path",
            crate::messages::diagnostic_prefix(),
            crate::surface::GROK_SKILL_RELATIVE
        );
    }

    let entries = LedgerStore::new(state_dir).unresolved_rejections()?;
    let memory_skill_section = match crate::memory_skill::reinjection_section(state_dir, config) {
        Ok(section) => section,
        Err(error) => {
            tracing::warn!(
                error = %error,
                "memory-skill reinjection failed; continuing with unresolved review findings"
            );
            eprintln!(
                "truth-mirror memory-skill reinjection failed; continuing with unresolved review findings: {error}"
            );
            String::new()
        }
    };
    let output = render(args.agent, &entries, &memory_skill_section);
    if !output.is_empty() {
        print!("{output}");
    }
    Ok(ExitCode::SUCCESS)
}

/// Whether Claude reinjection should no-op because this process is a Grok hook.
///
/// Only `Agent::Claude` is gated: that is the surface Grok auto-loads via Claude
/// harness compatibility. Native `Agent::Grok` still reinjects under Grok.
pub fn should_skip_claude_reinject_under_grok(agent: Agent, under_grok: bool) -> bool {
    matches!(agent, Agent::Claude) && under_grok
}

/// Detect a Grok hook process from Grok-only runner env vars.
pub fn under_grok_from_env() -> bool {
    under_grok_from_vars(
        env::var_os(GROK_SESSION_ID_ENV).as_deref(),
        env::var_os(GROK_HOOK_EVENT_ENV).as_deref(),
    )
}

fn under_grok_from_vars(session_id: Option<&OsStr>, hook_event: Option<&OsStr>) -> bool {
    env_nonempty(session_id) || env_nonempty(hook_event)
}

fn env_nonempty(value: Option<&OsStr>) -> bool {
    value.is_some_and(|v| !v.is_empty())
}

/// Roots to search for the Grok skill: state-dir *parent* (repo), cwd, git root.
/// Prefer paths tied to the target ledger (`state_dir`) so `--state-dir` cannot be
/// gated by an unrelated checkout's skill. Do not search inside `state_dir` itself
/// (skills live at `<repo>/.grok/...`, not under `.truth/`).
fn grok_skill_search_roots(state_dir: &Path) -> Vec<std::path::PathBuf> {
    let mut roots = Vec::new();
    if let Some(parent) = state_dir.parent() {
        push_unique(&mut roots, parent.to_path_buf());
    }
    if let Ok(cwd) = env::current_dir() {
        push_unique(&mut roots, cwd);
    }
    if let Some(git_root) = git_repo_root() {
        push_unique(&mut roots, git_root);
    }
    if roots.is_empty() {
        roots.push(Path::new(".").to_path_buf());
    }
    roots
}

fn push_unique(roots: &mut Vec<std::path::PathBuf>, path: std::path::PathBuf) {
    if !roots.iter().any(|root| root == &path) {
        roots.push(path);
    }
}

fn git_repo_root() -> Option<std::path::PathBuf> {
    let output = std::process::Command::new("git")
        .args(["rev-parse", "--show-toplevel"])
        .output()
        .ok()?;
    if !output.status.success() {
        return None;
    }
    let path = String::from_utf8(output.stdout).ok()?;
    let path = path.trim();
    if path.is_empty() {
        None
    } else {
        Some(std::path::PathBuf::from(path))
    }
}

pub fn render(agent: Agent, entries: &[LedgerEntry], memory_skill_section: &str) -> String {
    if entries.is_empty() && memory_skill_section.is_empty() {
        return String::new();
    }

    let agent_name = match agent {
        Agent::Claude => "Claude",
        Agent::Codex => "Codex",
        Agent::Pi => "Pi",
        Agent::Grok => "Grok",
    };

    let mut output = String::new();
    if !entries.is_empty() {
        output.push_str(&format!(
            "Truth Mirror unresolved rejections for {agent_name}. Address these before claiming completion or pushing.\n\n"
        ));
    }
    for entry in entries {
        output.push_str(&format!(
            "- commit: {}\n  status: {} {}\n  claim: {}\n",
            entry.commit_sha, entry.verdict, entry.disposition, entry.claim
        ));
        if !entry.summary.trim().is_empty() {
            output.push_str(&format!("  summary: {}\n", entry.summary));
        }
        if entry.findings.is_empty() {
            output.push_str("  findings: none\n");
        } else if !entry.structured_findings.is_empty() {
            output.push_str("  findings:\n");
            for finding in &entry.structured_findings {
                output.push_str(&format!("    - {}\n", finding.display_line()));
            }
        } else {
            output.push_str("  findings:\n");
            for finding in &entry.findings {
                output.push_str(&format!("    - {finding}\n"));
            }
        }
        if !entry.next_steps.is_empty() {
            output.push_str("  next steps:\n");
            for step in &entry.next_steps {
                output.push_str(&format!("    - {step}\n"));
            }
        }
        if !entry.raw_reviewer_output.trim().is_empty() {
            output.push_str("  raw reviewer output:\n");
            for line in entry.raw_reviewer_output.trim().lines() {
                output.push_str(&format!("    {line}\n"));
            }
        }
    }
    output.push_str(memory_skill_section);
    output
}

#[cfg(test)]
mod tests {
    use std::path::Path;

    use crate::{
        cli::Agent,
        config::{MemorySkillCandidateKind, TruthMirrorConfig},
        ledger::{
            FindingSeverity, LedgerEntry, MACHINE_LEDGER_FILE, ReviewerConfig, StructuredFinding,
            Verdict,
        },
        memory_skill::{
            CANDIDATE_SCHEMA_VERSION, CandidateStatus, EvidenceRef, MemorySkillCandidate,
            MemorySkillStore,
        },
    };

    use super::{render, should_skip_claude_reinject_under_grok, under_grok_from_vars};

    fn rejected_entry() -> LedgerEntry {
        LedgerEntry::new_at(
            "abc123",
            Verdict::Reject,
            "CLAIM: bad | verified: cargo test | evidence: tests:cargo-test",
            vec!["tests:cargo-test".to_owned()],
            ReviewerConfig::new("claude", "opus", false),
            vec!["unsupported".to_owned()],
            100,
        )
    }

    fn structured_entry() -> LedgerEntry {
        rejected_entry().with_structured_review(
            "The claim is unsupported.",
            vec![StructuredFinding {
                severity: FindingSeverity::High,
                title: "unsupported".to_owned(),
                body: "The evidence pointer does not prove the claim.".to_owned(),
                file: "src/lib.rs".to_owned(),
                line_start: 3,
                line_end: 4,
                confidence: 95,
                recommendation: "Add direct evidence.".to_owned(),
            }],
            vec!["Run cargo test.".to_owned()],
            r#"{"verdict":"REJECT"}"#,
        )
    }

    fn seed_memory_skill_candidate(state_dir: &Path, config: &TruthMirrorConfig) {
        let ledger_ref = EvidenceRef {
            kind: "ledger".to_owned(),
            path: Some(MACHINE_LEDGER_FILE.to_owned()),
            selector: Some("commit=abc123".to_owned()),
            ..EvidenceRef::default()
        };
        let store = MemorySkillStore::new(state_dir, &config.memory_skill).unwrap();
        store
            .write_candidate(&MemorySkillCandidate {
                schema_version: CANDIDATE_SCHEMA_VERSION,
                id: "msk_1".to_owned(),
                fingerprint: "sha256:v1:test".to_owned(),
                learning_key: "example".to_owned(),
                status: CandidateStatus::Pending,
                candidate_kind: MemorySkillCandidateKind::HowToSkill,
                scope: "project".to_owned(),
                source_commit: "abc123".to_owned(),
                source_claim: "CLAIM: example | verified: cargo test | evidence: tests:example"
                    .to_owned(),
                truth_label: Verdict::Pass,
                ledger_entry_ref: ledger_ref.clone(),
                source_run_ref: EvidenceRef {
                    kind: "run".to_owned(),
                    path: Some("runs/run-1.json".to_owned()),
                    run_id: Some("run-1".to_owned()),
                    artifact: Some("review.json".to_owned()),
                    ..EvidenceRef::default()
                },
                occurrence_count: 2,
                occurrence_refs: vec![ledger_ref.clone()],
                slug: "example".to_owned(),
                title: "Example".to_owned(),
                description: "Capture an example reusable workflow.".to_owned(),
                when_to_use: vec!["Use for example workflows.".to_owned()],
                procedure_steps: vec!["Inspect the ledger evidence.".to_owned()],
                pitfalls: vec!["Do not self-approve.".to_owned()],
                verification_steps: vec!["Run cargo test.".to_owned()],
                evidence: vec![ledger_ref],
                created_at_unix: 100,
            })
            .unwrap();
    }

    #[test]
    fn reinjection_is_quiet_when_empty() {
        assert_eq!(render(Agent::Claude, &[], ""), "");
    }

    #[test]
    fn reinjection_mentions_each_supported_agent() {
        for agent in [Agent::Claude, Agent::Codex, Agent::Pi, Agent::Grok] {
            let output = render(agent, &[rejected_entry()], "");

            assert!(output.contains("Truth Mirror unresolved rejections"));
            assert!(output.contains("abc123"));
            assert!(output.contains("unsupported"));
        }
    }

    #[test]
    fn under_grok_requires_nonempty_grok_only_env() {
        use std::ffi::OsStr;

        assert!(!under_grok_from_vars(None, None));
        assert!(!under_grok_from_vars(Some(OsStr::new("")), None));
        assert!(!under_grok_from_vars(None, Some(OsStr::new(""))));
        assert!(under_grok_from_vars(Some(OsStr::new("sess-1")), None));
        assert!(under_grok_from_vars(
            None,
            Some(OsStr::new("user_prompt_submit"))
        ));
        assert!(under_grok_from_vars(
            Some(OsStr::new("sess-1")),
            Some(OsStr::new("user_prompt_submit"))
        ));
    }

    #[test]
    fn claude_reinject_is_gated_under_grok_other_agents_are_not() {
        assert!(should_skip_claude_reinject_under_grok(Agent::Claude, true));
        assert!(!should_skip_claude_reinject_under_grok(
            Agent::Claude,
            false
        ));
        assert!(!should_skip_claude_reinject_under_grok(Agent::Grok, true));
        assert!(!should_skip_claude_reinject_under_grok(Agent::Codex, true));
        assert!(!should_skip_claude_reinject_under_grok(Agent::Pi, true));
    }

    #[test]
    fn reinjection_includes_structured_provenance() {
        let output = render(Agent::Codex, &[structured_entry()], "");

        assert!(output.contains("status: REJECT open"));
        assert!(output.contains("The claim is unsupported."));
        assert!(output.contains("high [src/lib.rs:3-4] unsupported"));
        assert!(output.contains("Run cargo test."));
        assert!(output.contains(r#"{"verdict":"REJECT"}"#));
    }

    #[test]
    fn reinjection_can_render_memory_skill_section_without_rejections() {
        let temp = tempfile::tempdir().unwrap();
        let mut config = TruthMirrorConfig::default();
        config.skills.enabled = true;
        config.memory_skill.enabled = true;
        seed_memory_skill_candidate(temp.path(), &config);

        let section = crate::memory_skill::reinjection_section(temp.path(), &config).unwrap();
        let output = render(Agent::Codex, &[], &section);

        assert!(output.contains("pending memory/skill candidates"));
        assert!(output.contains("msk_1 how_to_skill example"));
        assert!(output.contains("memory-skill show msk_1"));
        assert!(!output.contains("approve"));
        assert!(!output.contains("apply"));
        assert!(!output.contains("reject"));
    }

    #[test]
    fn reinjection_combines_rejections_and_memory_skill_section() {
        let temp = tempfile::tempdir().unwrap();
        let mut config = TruthMirrorConfig::default();
        config.skills.enabled = true;
        config.memory_skill.enabled = true;
        seed_memory_skill_candidate(temp.path(), &config);

        let section = crate::memory_skill::reinjection_section(temp.path(), &config).unwrap();
        let output = render(Agent::Codex, &[structured_entry()], &section);

        assert!(output.contains("Truth Mirror unresolved rejections for Codex"));
        assert!(output.contains("high [src/lib.rs:3-4] unsupported"));
        assert!(output.contains("pending memory/skill candidates"));
        assert!(output.contains("msk_1 how_to_skill example"));
        assert!(output.contains("raw reviewer output:\n    {\"verdict\":\"REJECT\"}\n"));
        assert!(output.contains("memory-skill show msk_1"));
    }
}