truth-mirror 0.9.1

Truthfulness gate and adversarial reviewer harness for AI coding agents.
Documentation
//! Resolution-as-petition flow and human-only waive lane.
//!
//! `truth-mirror resolve <rejection-sha> --fixed-by <fix-sha>` enqueues a
//! RESOLUTION re-review. The reviewer is asked whether the fix materially
//! addresses each finding; an accepting verdict — `PASS`, or `FLAG` with its
//! non-blocking debt (see [`petition_accepts`], the one accept criterion) —
//! transitions the original rejection to `resolved`. A `REJECT` leaves the
//! bounded attempt counter spent; once the counter hits
//! [`MAX_PETITION_ATTEMPTS`], the rejection escalates to `needs-human` and
//! stays visible in reinjection but is no longer agent-actionable.
//!
//! `truth-mirror resolve <rejection-sha> --waive --reason <text>` is the
//! human-only waive lane. It refuses to run when stdin is not an
//! interactive TTY so agents structurally cannot invoke it.

use std::{
    io::{self, IsTerminal, Write},
    path::Path,
    process::ExitCode,
    sync::atomic::{AtomicI32, Ordering},
};

use anyhow::Result;

use crate::{
    cli::ResolveArgs,
    config::TruthMirrorConfig,
    ledger::{LedgerStore, ResolutionKind, Verdict},
};

/// Maximum number of `resolve --fixed-by` attempts allowed before the
/// rejection escalates to `needs-human`. The brief fixes this at 2
/// (the 3rd attempt is refused and escalates).
pub const MAX_PETITION_ATTEMPTS: u32 = 2;

pub fn run(args: ResolveArgs, state_dir: &Path, _config: &TruthMirrorConfig) -> Result<ExitCode> {
    let store = LedgerStore::new(state_dir);

    if args.waive {
        return run_waive(args, &store);
    }
    if let Some(fix_sha) = args.fixed_by {
        return run_petition(args.rejection_sha, fix_sha, &store, state_dir);
    }

    anyhow::bail!(
        "resolve requires either --fixed-by <fix-sha> (agent petition) or --waive --reason <text> (human-only)"
    )
}

fn run_waive(args: ResolveArgs, store: &LedgerStore) -> Result<ExitCode> {
    let reason = args
        .reason
        .as_deref()
        .map(str::trim)
        .filter(|value| !value.is_empty())
        .ok_or_else(|| anyhow::anyhow!("--waive requires a non-empty --reason"))?;

    if !stdin_is_tty() {
        anyhow::bail!(
            "--waive requires an interactive terminal (TTY); refuse when stdin is not a tty so agents structurally cannot invoke it"
        );
    }

    let entry = store
        .waive(&args.rejection_sha, reason)
        .map_err(|error| anyhow::anyhow!(error.to_string()))?;
    println!(
        "truth-mirror: waived {} via human lane ({:?})",
        entry.commit_sha,
        ResolutionKind::Waived
    );
    Ok(ExitCode::SUCCESS)
}

fn run_petition(
    rejection_sha: String,
    fix_sha: String,
    store: &LedgerStore,
    state_dir: &Path,
) -> Result<ExitCode> {
    let original = store.show(&rejection_sha).map_err(|error| {
        anyhow::anyhow!("rejection {rejection_sha} not found in ledger: {error}")
    })?;
    if !original.is_unresolved_rejection() {
        let disposition = original.disposition;
        let verdict = original.verdict;
        let petition_attempts = store.petition_attempts_for(&rejection_sha).unwrap_or(0);
        anyhow::bail!(
            "rejection {rejection_sha} is not open (verdict={verdict}, disposition={disposition}, petition_attempts={petition_attempts}); no petition review is needed"
        );
    }

    // Guards that must NOT spend an attempt: a mistyped fix SHA (two typos
    // would otherwise exhaust the bound and escalate to needs-human without a
    // single real review), and a petition already in flight for this
    // rejection (a second enqueue would burn its attempt and then dead-letter
    // once the first one transitions the rejection).
    if let Some(reason) = fix_commit_unreviewable(&fix_sha) {
        anyhow::bail!(
            "refusing petition without spending an attempt: fix commit {fix_sha} cannot be reviewed ({reason})"
        );
    }
    let in_flight = crate::reviewer::ReviewQueue::new(state_dir)
        .pending()
        .map_err(|error| anyhow::anyhow!(error.to_string()))?
        .into_iter()
        .any(|item| item.petition_for.as_deref() == Some(rejection_sha.as_str()));
    if in_flight {
        anyhow::bail!(
            "refusing petition without spending an attempt: a petition for {rejection_sha} is already queued; let the watcher drain it first"
        );
    }

    let prior_attempts = store.petition_attempts_for(&rejection_sha).unwrap_or(0);
    if prior_attempts >= MAX_PETITION_ATTEMPTS {
        let reason = format!(
            "petition refused: {MAX_PETITION_ATTEMPTS} attempts already recorded for {rejection_sha}; escalating to needs-human"
        );
        store
            .escalate_to_needs_human(&rejection_sha, &reason)
            .map_err(|error| anyhow::anyhow!(error.to_string()))?;
        println!(
            "truth-mirror: refused 3rd petition attempt for {rejection_sha}; escalation to needs-human recorded"
        );
        return Ok(ExitCode::SUCCESS);
    }

    // Two writes make a petition: (1) the attempt bookkeeping entry on the
    // rejection so the counter is spent even before the reviewer verdict
    // lands, and (2) the QUEUE item for the fix commit, tagged with the
    // original rejection SHA so drain builds a petition job. The watcher
    // (or `truth-mirror watch --once`) drains it; execute_review_job swaps
    // in the petition prompt and apply_petition_transition transitions the
    // original rejection based on the verdict.
    let next_attempts = prior_attempts.saturating_add(1);
    let reason = format!(
        "petition review enqueued: fix={fix_sha}, attempts={next_attempts}/{MAX_PETITION_ATTEMPTS}"
    );
    store
        .append_petition_transition(
            &rejection_sha,
            crate::ledger::Disposition::Open,
            ResolutionKind::Resolved,
            &reason,
            next_attempts,
        )
        .map_err(|error| anyhow::anyhow!(error.to_string()))?;
    crate::reviewer::ReviewQueue::new(state_dir)
        .enqueue_petition(&fix_sha, &rejection_sha)
        .map_err(|error| anyhow::anyhow!(error.to_string()))?;
    // Same contract as the post-commit hook: enqueuing must guarantee a
    // consumer exists. Best-effort — a failed spawn must not fail the
    // petition; the next enqueue (or a manual `watch`) retries.
    if let Err(error) = crate::watcher::ensure_watcher(state_dir) {
        eprintln!("truth-mirror: warning: failed to ensure a review watcher: {error}");
    }
    println!(
        "truth-mirror: enqueued petition review for fix={fix_sha} against rejection={rejection_sha} (attempt {next_attempts}/{MAX_PETITION_ATTEMPTS}); the watcher will drain and transition the rejection based on the verdict"
    );
    Ok(ExitCode::SUCCESS)
}

/// Why `fix_sha` cannot be petition-reviewed, or `None` when it can.
/// Outside a git work tree this returns `None` — there is nothing to check
/// against, and refusing would break ledger-only setups; the review itself
/// will fail loudly later if the object never materializes.
fn fix_commit_unreviewable(fix_sha: &str) -> Option<String> {
    let in_repo = std::process::Command::new("git")
        .args(["rev-parse", "--is-inside-work-tree"])
        .output()
        .map(|out| out.status.success())
        .unwrap_or(false);
    if !in_repo {
        return None;
    }
    let exists = std::process::Command::new("git")
        .args(["cat-file", "-e", &format!("{fix_sha}^{{commit}}")])
        .output()
        .map(|out| out.status.success())
        .unwrap_or(false);
    if exists {
        None
    } else {
        Some("no such commit object in this repository".to_owned())
    }
}

/// Whether stdin is currently attached to an interactive terminal. Tests
/// override this via [`set_stdin_tty_override_for_testing`].
pub fn stdin_is_tty() -> bool {
    if let Some(value) = stdin_tty_override() {
        return value;
    }
    io::stdin().is_terminal()
}

const STDIN_TTY_OVERRIDE_NONE: i32 = i32::MIN;

static STDIN_TTY_OVERRIDE: AtomicI32 = AtomicI32::new(STDIN_TTY_OVERRIDE_NONE);

fn stdin_tty_override() -> Option<bool> {
    let value = STDIN_TTY_OVERRIDE.load(Ordering::SeqCst);
    if value == STDIN_TTY_OVERRIDE_NONE {
        None
    } else {
        Some(value != 0)
    }
}

/// Test-only override: `Some(true)` / `Some(false)` forces the tty check; the
/// default sentinel (no override set) defers to the real stdin.
///
/// Compiled out of release builds (`debug_assertions` off) so no production
/// binary or in-process consumer can flip the TTY gate that keeps agents out
/// of the `--waive` lane; integration tests build with dev profile and keep
/// access. Hidden from docs — this is test plumbing, not API.
#[cfg(any(test, debug_assertions))]
#[doc(hidden)]
pub fn set_stdin_tty_override_for_testing(value: Option<bool>) {
    match value {
        Some(flag) => {
            STDIN_TTY_OVERRIDE.store(flag as i32, Ordering::SeqCst);
        }
        None => {
            STDIN_TTY_OVERRIDE.store(STDIN_TTY_OVERRIDE_NONE, Ordering::SeqCst);
        }
    }
}

/// Whether `verdict` accepts a petition re-review (i.e. the reviewer accepted
/// that the fix materially addresses each finding). `PASS` accepts outright;
/// `FLAG` ALSO accepts — the petition prompt defines FLAG as "the fix
/// materially addresses the findings" plus non-blocking debt, and that debt
/// lives on the petition review entry (surfaced via `truth-mirror debt`),
/// not on the original rejection. There is no separate `ACCEPT` variant on
/// the wire. This is the ONE accept criterion — the petition plumbing in
/// `reviewer::apply_petition_transition` delegates here.
pub fn petition_accepts(verdict: Verdict) -> bool {
    matches!(verdict, Verdict::Pass | Verdict::Flag)
}

/// Helper for printing an interactive waiver prompt. Surfaced so the
/// `--waive --reason` flow can be exercised end-to-end without consuming
/// the parent's stdin.
#[doc(hidden)]
pub fn prompt_waiver_reason_to_writer(writer: &mut dyn Write, default: &str) -> io::Result<()> {
    write!(writer, "waiver reason [{default}]: ")?;
    writer.flush()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn stdin_tty_override_round_trip() {
        set_stdin_tty_override_for_testing(Some(true));
        assert!(stdin_is_tty());
        set_stdin_tty_override_for_testing(Some(false));
        assert!(!stdin_is_tty());
        set_stdin_tty_override_for_testing(None);
    }

    #[test]
    fn petition_accepts_pass_and_flag_but_not_reject() {
        // FLAG accepts by definition: the petition prompt defines FLAG as
        // "the fix materially addresses the findings" plus non-blocking debt.
        assert!(petition_accepts(Verdict::Pass));
        assert!(petition_accepts(Verdict::Flag));
        assert!(!petition_accepts(Verdict::Reject));
    }

    #[test]
    fn max_petition_attempts_is_two() {
        assert_eq!(MAX_PETITION_ATTEMPTS, 2);
    }

    #[test]
    fn prompt_helper_writes_default_text() {
        let mut buffer = Vec::new();
        prompt_waiver_reason_to_writer(&mut buffer, "approved exception").unwrap();
        let text = String::from_utf8(buffer).unwrap();
        assert_eq!(text, "waiver reason [approved exception]: ");
    }
}