use claudius::{ContentBlock, MessageParam, MessageParamContent};
use serde::{Deserialize, Serialize};
use serde_json::{Value, json};
pub const VERDICT_TOOL_NAME: &str = "verdict";
pub const ESCALATE_TOOL_NAME: &str = "escalate";
#[derive(Clone, Copy, Debug, Deserialize, Eq, Ord, PartialEq, PartialOrd, Serialize)]
#[serde(rename_all = "lowercase")]
pub enum Severity {
Blocker,
Required,
Suggestion,
}
impl Severity {
pub fn is_mandate(self) -> bool {
matches!(self, Severity::Blocker | Severity::Required)
}
pub fn label(self) -> &'static str {
match self {
Severity::Blocker => "blocker",
Severity::Required => "required",
Severity::Suggestion => "suggestion",
}
}
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Finding {
pub severity: Severity,
#[serde(rename = "where")]
pub where_: String,
pub what: String,
pub why: String,
}
#[derive(Clone, Debug, Deserialize, PartialEq, Serialize)]
pub struct Verdict {
pub sufficient: bool,
pub summary: String,
#[serde(default)]
pub findings: Vec<Finding>,
#[serde(default)]
pub acceptance: Vec<String>,
}
impl Verdict {
pub fn from_input(input: &Value) -> Result<Verdict, String> {
serde_json::from_value(input.clone()).map_err(|err| format!("malformed verdict: {err}"))
}
pub fn validate(&self) -> Result<(), String> {
if !self.sufficient && !self.findings.iter().any(|f| f.severity.is_mandate()) {
return Err(
"malformed verdict: sufficient=false requires at least one blocker or \
required finding; emit a work order, not a bare rejection"
.to_string(),
);
}
Ok(())
}
pub fn effective_sufficient(&self, pedantic: bool) -> bool {
if pedantic {
self.sufficient && self.findings.is_empty()
} else {
self.sufficient
}
}
pub fn suggestions(&self) -> Vec<&Finding> {
self.findings
.iter()
.filter(|f| matches!(f.severity, Severity::Suggestion))
.collect()
}
pub fn render_markdown(&self) -> String {
let mut out = String::new();
let status = if self.sufficient {
"sufficient"
} else {
"insufficient"
};
out.push_str(&format!("# Verdict: {status}\n\n"));
out.push_str(self.summary.trim());
out.push_str("\n\n## Findings\n\n");
if self.findings.is_empty() {
out.push_str("No findings.\n");
} else {
for (i, finding) in self.findings.iter().enumerate() {
out.push_str(&format!(
"{}. **{}** — {}\n - What: {}\n - Why: {}\n",
i + 1,
finding.severity.label(),
finding.where_,
finding.what,
finding.why,
));
}
}
if !self.acceptance.is_empty() {
out.push_str("\n## Acceptance\n\n");
for item in &self.acceptance {
out.push_str(&format!("- {item}\n"));
}
}
out
}
}
pub fn verdict_input_schema() -> Value {
json!({
"type": "object",
"required": ["sufficient", "summary", "findings"],
"properties": {
"sufficient": {"type": "boolean"},
"summary": {
"type": "string",
"description": "One paragraph. The next agent has no other context about your reasoning."
},
"findings": {
"type": "array",
"items": {
"type": "object",
"required": ["severity", "where", "what", "why"],
"properties": {
"severity": {"enum": ["blocker", "required", "suggestion"]},
"where": {"type": "string", "description": "file:line or component"},
"what": {"type": "string", "description": "Imperative. 'Add X', not 'X is missing'."},
"why": {"type": "string", "description": "Tie to the plan or the design thread."}
}
}
},
"acceptance": {
"type": "array", "items": {"type": "string"},
"description": "What you will verify on the next pass. Specific enough that passing is checkable."
}
}
})
}
pub fn verdict_tool_description() -> &'static str {
"Render your verdict. The next agent has NO context except what you write here. \
Write a work order, not a judgment."
}
#[derive(Clone, Copy, Debug, Default)]
pub struct ExchangeGuard {
forced: bool,
bounced: bool,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ExchangeEvent {
NoVerdict,
Malformed(String),
Ok,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ExchangeAction {
ForceVerdict,
Bounce(String),
Accept,
Fail(String),
}
impl ExchangeGuard {
pub fn observe(&mut self, event: ExchangeEvent) -> ExchangeAction {
match event {
ExchangeEvent::Ok => ExchangeAction::Accept,
ExchangeEvent::NoVerdict => {
if self.forced {
ExchangeAction::Fail(
"judge ended its turn without calling verdict even under forced \
tool choice"
.to_string(),
)
} else {
self.forced = true;
ExchangeAction::ForceVerdict
}
}
ExchangeEvent::Malformed(reason) => {
if self.bounced {
ExchangeAction::Fail(format!("second malformed verdict: {reason}"))
} else {
self.bounced = true;
ExchangeAction::Bounce(reason)
}
}
}
}
}
pub fn tool_use_inputs_since(messages: &[MessageParam], from: usize, tool: &str) -> Vec<Value> {
let mut inputs = Vec::new();
for message in messages.iter().skip(from) {
let MessageParamContent::Array(blocks) = &message.content else {
continue;
};
for block in blocks {
if let ContentBlock::ToolUse(tool_use) = block
&& tool_use.name == tool
{
inputs.push(tool_use.input.clone());
}
}
}
inputs
}
pub fn last_verdict_input(messages: &[MessageParam], from: usize) -> Option<Value> {
tool_use_inputs_since(messages, from, VERDICT_TOOL_NAME)
.into_iter()
.next_back()
}
pub fn find_escalation(messages: &[MessageParam], from: usize) -> Option<String> {
tool_use_inputs_since(messages, from, ESCALATE_TOOL_NAME)
.into_iter()
.next()
.map(|input| {
input
.get("reason")
.and_then(Value::as_str)
.unwrap_or("escalation requested without a reason")
.to_string()
})
}
#[cfg(test)]
mod tests {
use claudius::{MessageRole, ToolUseBlock};
use super::*;
fn verdict(sufficient: bool, findings: Vec<Finding>) -> Verdict {
Verdict {
sufficient,
summary: "summary".to_string(),
findings,
acceptance: Vec::new(),
}
}
fn finding(severity: Severity) -> Finding {
Finding {
severity,
where_: "src/lib.rs:1".to_string(),
what: "Add the thing".to_string(),
why: "The plan demands it".to_string(),
}
}
#[test]
fn schema_matches_plan() {
let schema = verdict_input_schema();
assert_eq!(
schema["required"],
json!(["sufficient", "summary", "findings"])
);
assert_eq!(
schema["properties"]["findings"]["items"]["required"],
json!(["severity", "where", "what", "why"])
);
assert_eq!(
schema["properties"]["findings"]["items"]["properties"]["severity"]["enum"],
json!(["blocker", "required", "suggestion"])
);
assert_eq!(schema["properties"]["acceptance"]["type"], json!("array"));
}
#[test]
fn shipped_tool_manifest_matches_the_mandated_schema() {
let manifest: Value =
serde_json::from_str(include_str!("../../init/tools/verdict.json")).unwrap();
assert_eq!(manifest["input_schema"], verdict_input_schema());
assert_eq!(
manifest["description"].as_str().unwrap(),
verdict_tool_description()
);
}
#[test]
fn parses_verdict_with_where_keyword() {
let input = json!({
"sufficient": false,
"summary": "Not done.",
"findings": [
{"severity": "blocker", "where": "ci", "what": "Fix CI", "why": "It fails"}
],
"acceptance": ["ci passes"]
});
let verdict = Verdict::from_input(&input).unwrap();
assert!(!verdict.sufficient);
assert_eq!(verdict.findings[0].where_, "ci");
assert_eq!(verdict.acceptance, vec!["ci passes".to_string()]);
}
#[test]
fn missing_required_fields_is_an_error() {
let input = json!({"sufficient": true});
assert!(Verdict::from_input(&input).is_err());
}
#[test]
fn findings_and_acceptance_default_to_empty() {
let input = json!({"sufficient": true, "summary": "Done.", "findings": []});
let verdict = Verdict::from_input(&input).unwrap();
assert!(verdict.findings.is_empty());
assert!(verdict.acceptance.is_empty());
}
#[test]
fn rejection_without_mandate_is_malformed() {
let bare = verdict(false, vec![]);
assert!(bare.validate().is_err());
let suggestion_only = verdict(false, vec![finding(Severity::Suggestion)]);
assert!(suggestion_only.validate().is_err());
let with_required = verdict(false, vec![finding(Severity::Required)]);
assert!(with_required.validate().is_ok());
let with_blocker = verdict(false, vec![finding(Severity::Blocker)]);
assert!(with_blocker.validate().is_ok());
let passing = verdict(true, vec![]);
assert!(passing.validate().is_ok());
}
#[test]
fn pedantic_promotes_suggestions() {
let v = verdict(true, vec![finding(Severity::Suggestion)]);
assert!(v.effective_sufficient(false));
assert!(!v.effective_sufficient(true));
let clean = verdict(true, vec![]);
assert!(clean.effective_sufficient(true));
}
#[test]
fn render_markdown_is_a_work_order() {
let v = Verdict {
sufficient: false,
summary: "CI passes but the plan is incomplete.".to_string(),
findings: vec![finding(Severity::Blocker)],
acceptance: vec!["the thing exists".to_string()],
};
let md = v.render_markdown();
assert!(md.starts_with("# Verdict: insufficient\n"));
assert!(md.contains("CI passes but the plan is incomplete."));
assert!(md.contains("1. **blocker** — src/lib.rs:1"));
assert!(md.contains("- What: Add the thing"));
assert!(md.contains("- Why: The plan demands it"));
assert!(md.contains("## Acceptance"));
assert!(md.contains("- the thing exists"));
}
#[test]
fn render_markdown_without_findings() {
let v = verdict(true, vec![]);
let md = v.render_markdown();
assert!(md.starts_with("# Verdict: sufficient\n"));
assert!(md.contains("No findings."));
assert!(!md.contains("## Acceptance"));
}
#[test]
fn exchange_guard_forces_exactly_once() {
let mut guard = ExchangeGuard::default();
assert_eq!(
guard.observe(ExchangeEvent::NoVerdict),
ExchangeAction::ForceVerdict
);
match guard.observe(ExchangeEvent::NoVerdict) {
ExchangeAction::Fail(_) => {}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn exchange_guard_bounces_exactly_once() {
let mut guard = ExchangeGuard::default();
assert_eq!(
guard.observe(ExchangeEvent::Malformed("no mandate".to_string())),
ExchangeAction::Bounce("no mandate".to_string())
);
match guard.observe(ExchangeEvent::Malformed("still no mandate".to_string())) {
ExchangeAction::Fail(_) => {}
other => panic!("expected Fail, got {other:?}"),
}
}
#[test]
fn exchange_guard_accepts_after_force_then_bounce() {
let mut guard = ExchangeGuard::default();
assert_eq!(
guard.observe(ExchangeEvent::NoVerdict),
ExchangeAction::ForceVerdict
);
assert_eq!(
guard.observe(ExchangeEvent::Malformed("bad".to_string())),
ExchangeAction::Bounce("bad".to_string())
);
assert_eq!(guard.observe(ExchangeEvent::Ok), ExchangeAction::Accept);
}
fn tool_use_message(name: &str, input: Value) -> MessageParam {
MessageParam::new_with_blocks(
vec![ContentBlock::ToolUse(ToolUseBlock {
id: "toolu_1".to_string(),
input,
name: name.to_string(),
cache_control: None,
})],
MessageRole::Assistant,
)
}
#[test]
fn extracts_last_verdict_after_index() {
let messages = vec![
MessageParam::user("hello"),
tool_use_message(VERDICT_TOOL_NAME, json!({"sufficient": false})),
MessageParam::user("next pass"),
tool_use_message(
VERDICT_TOOL_NAME,
json!({"sufficient": true, "summary": "ok", "findings": []}),
),
];
let input = last_verdict_input(&messages, 2).unwrap();
assert_eq!(input["sufficient"], json!(true));
assert!(last_verdict_input(&messages, 4).is_none());
}
#[test]
fn finds_escalation_reason() {
let messages = vec![
MessageParam::user("go"),
tool_use_message(ESCALATE_TOOL_NAME, json!({"reason": "need a human"})),
];
assert_eq!(
find_escalation(&messages, 0).as_deref(),
Some("need a human")
);
assert!(find_escalation(&messages, 2).is_none());
let no_reason = vec![tool_use_message(ESCALATE_TOOL_NAME, json!({}))];
assert_eq!(
find_escalation(&no_reason, 0).as_deref(),
Some("escalation requested without a reason")
);
}
}