ritalin 0.4.7

Executive function for AI coding agents. Focus their intelligence, ground their work, stop the avoidable mistakes.
//! `ritalin export-contract` — emit a subagent-ready briefing.
//!
//! Subagents spawned via Claude Code's Task/Agent tool run in isolated context
//! (own system prompt, own context window, depth=1, only the summary returns).
//! They cannot see `.ritalin/` state unless you tell them. This command produces
//! a ready-to-paste text block the parent agent includes in the delegation
//! prompt so the subagent stays inside the parent's contract.
//!
//! Zero-arg. Read-only. Does not touch the ledger, marker, or scope.

use serde::Serialize;

use crate::error::AppError;
use crate::gate_eval;
use crate::ledger::{
    evidence, is_initialized, obligations, obligations::Obligation, scope::Scope, state_dir,
};
use crate::output::{self, Ctx};

#[derive(Serialize)]
struct OpenObligation {
    id: String,
    claim: String,
    kind: String,
    critical: bool,
    proof_cmd: String,
    last_exit_code: Option<i32>,
}

#[derive(Serialize)]
struct RemainingOpen {
    ids: Vec<String>,
    critical: usize,
    advisory: usize,
}

#[derive(Serialize)]
struct ExportContractResult {
    outcome: String,
    obligations_total: usize,
    remaining_open: RemainingOpen,
    open_obligations: Vec<OpenObligation>,
    briefing: String,
}

pub fn run(ctx: Ctx) -> Result<(), AppError> {
    let cwd = std::env::current_dir()?;
    if !is_initialized(&cwd) {
        return Err(AppError::NotInitialized);
    }
    let dir = state_dir(&cwd);

    let scope = Scope::read(&dir)?;
    let all_obs = obligations::read_all(&dir)?;
    let evidence_index = evidence::index_by_obligation(&dir)?;
    // Tolerant: a briefing must still render if one obligation's scope is
    // broken — that obligation simply appears as open.
    let project_root = dir.parent().unwrap_or(&cwd);
    let scope_hashes = gate_eval::compute_scope_hashes_tolerant(&all_obs, project_root);
    let eval = gate_eval::evaluate(&all_obs, &evidence_index, &scope_hashes);

    // Merge open critical + open advisory, preserving the add-order of obligations.
    let open_refs: Vec<&Obligation> = eval
        .open_critical
        .iter()
        .copied()
        .chain(eval.open_advisory.iter().copied())
        .collect();

    let open_obligations: Vec<OpenObligation> = open_refs
        .iter()
        .map(|ob| {
            let last_exit = evidence_index
                .get(&ob.id)
                .and_then(|recs| recs.last().map(|e| e.exit_code));
            OpenObligation {
                id: ob.id.clone(),
                claim: ob.claim.clone(),
                kind: ob.kind.to_string(),
                critical: ob.critical,
                proof_cmd: ob.proof_cmd.clone(),
                last_exit_code: last_exit,
            }
        })
        .collect();

    let remaining_open = RemainingOpen {
        ids: open_obligations.iter().map(|o| o.id.clone()).collect(),
        critical: eval.open_critical.len(),
        advisory: eval.open_advisory.len(),
    };

    let briefing = render_briefing(
        &scope.outcome,
        all_obs.len(),
        &remaining_open,
        &open_obligations,
    );

    let result = ExportContractResult {
        outcome: scope.outcome,
        obligations_total: all_obs.len(),
        remaining_open,
        open_obligations,
        briefing,
    };

    output::print_success_or(ctx, &result, |r| {
        // Human mode: print the briefing raw so it can be copied straight
        // into a Task/Agent delegation prompt. No colour, no adornment.
        println!("{}", r.briefing);
    });

    Ok(())
}

/// Render the subagent briefing text.
///
/// Three distinct states are flagged explicitly so the subagent is not
/// misled:
///   • no obligations added yet          → still forbid claiming completion
///   • obligations exist, all discharged → still forbid claiming gate passed
///   • open obligations remain           → list them with claims + proofs
fn render_briefing(
    outcome: &str,
    obligations_total: usize,
    remaining: &RemainingOpen,
    open: &[OpenObligation],
) -> String {
    let summary_line = if obligations_total == 0 {
        "Open obligations: none yet (the parent has not added any obligations to the ledger)."
            .to_string()
    } else if remaining.ids.is_empty() {
        "Open obligations: none — every obligation in the ledger has passing evidence. Do not claim the ritalin gate passed; let the parent run `ritalin gate`.".to_string()
    } else {
        format!(
            "Open obligations: {} ({} critical, {} advisory).",
            remaining.ids.join(", "),
            remaining.critical,
            remaining.advisory
        )
    };

    let list_block = if open.is_empty() {
        String::new()
    } else {
        let mut s = String::from("\n");
        for o in open {
            let crit = if o.critical { "critical" } else { "advisory" };
            s.push_str(&format!(
                "- {id} [{kind}, {crit}]: {claim}\n",
                id = o.id,
                kind = o.kind,
                crit = crit,
                claim = o.claim
            ));
        }
        s
    };

    let proofs_block = if open.is_empty() {
        "(no open proofs to run)".to_string()
    } else {
        let mut s = String::new();
        for o in open {
            s.push_str(&format!("- {}: `{}`\n", o.id, o.proof_cmd));
        }
        s.trim_end().to_string()
    };

    format!(
        "You are a delegated implementation subagent. This briefing is the current contract snapshot for your task.\n\
         \n\
         Advance this outcome with edits grounded in files you read this turn — the parent verifies your work against the proofs below:\n\
         \n\
         OUTCOME: {outcome}\n\
         \n\
         **Anti-drift rule:** if you have not read it in this subagent turn, do not state it as a fact — file contents, API shapes, config values, visual properties, version numbers, all of it.\n\
         \n\
         {summary_line}{list_block}\n\
         How the parent will later verify them:\n\
         {proofs_block}\n\
         \n\
         Return exactly this format:\n\
         1. Changes made (files, functions, lines).\n\
         2. Facts you checked from source in this turn (with file:line cites).\n\
         3. Which open obligations your changes help satisfy (by ID).\n\
         4. Remaining blockers or uncertainties.\n\
         5. Suggested proof runs for the parent agent.\n\
         \n\
         Do not:\n\
         - edit `.ritalin/` or `.task-incomplete`, or touch the obligation list\n\
         - claim the ritalin gate passed, or mark the task done unless your changes satisfy the listed obligations\n",
    )
}

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

    fn open(id: &str, claim: &str, critical: bool, proof: &str) -> OpenObligation {
        OpenObligation {
            id: id.into(),
            claim: claim.into(),
            kind: "other".into(),
            critical,
            proof_cmd: proof.into(),
            last_exit_code: None,
        }
    }

    #[test]
    fn briefing_lists_open_obligations_with_proofs() {
        let remaining = RemainingOpen {
            ids: vec!["O-001".into(), "O-002".into()],
            critical: 1,
            advisory: 1,
        };
        let open = vec![
            open(
                "O-001",
                "Hero overlay exact colour",
                true,
                "grep -F -- 'rgba' theme.css",
            ),
            open(
                "O-002",
                "Docs up-to-date",
                false,
                "test -f docs/changelog.md",
            ),
        ];
        let out = render_briefing("Ship v0.3.1", 2, &remaining, &open);
        assert!(out.contains("OUTCOME: Ship v0.3.1"));
        assert!(out.contains("Open obligations: O-001, O-002"));
        assert!(out.contains("O-001 [other, critical]: Hero overlay exact colour"));
        assert!(out.contains("O-002 [other, advisory]: Docs up-to-date"));
        assert!(out.contains("grep -F -- 'rgba' theme.css"));
        assert!(out.contains("Anti-drift rule"));
        assert!(out.contains("Do not:"));
        assert!(out.contains("claim the ritalin gate passed"));
    }

    #[test]
    fn briefing_handles_no_obligations() {
        let remaining = RemainingOpen {
            ids: Vec::new(),
            critical: 0,
            advisory: 0,
        };
        let out = render_briefing("Fresh start", 0, &remaining, &[]);
        assert!(out.contains("Open obligations: none yet"));
        assert!(out.contains("(no open proofs to run)"));
    }

    #[test]
    fn briefing_handles_all_proved() {
        let remaining = RemainingOpen {
            ids: Vec::new(),
            critical: 0,
            advisory: 0,
        };
        let out = render_briefing("Done work", 5, &remaining, &[]);
        assert!(out.contains("every obligation in the ledger has passing evidence"));
        assert!(out.contains("Do not claim the ritalin gate passed"));
    }
}