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)?;
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);
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| {
println!("{}", r.briefing);
});
Ok(())
}
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"));
}
}