use super::{Classifier, ClassifyInput, ClassifyOutput};
use anyhow::{anyhow, Context};
use std::process::Command;
pub const DEFAULT_MODEL: &str = "claude-haiku-4-5";
pub const IN_CLASSIFIER_ENV: &str = "TJ_IN_CLASSIFIER";
pub trait CommandRunner: Send + Sync {
fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String>;
}
fn base_claude_command(model: &str) -> Command {
let mut cmd = Command::new("claude");
cmd.arg("-p")
.arg("--model")
.arg(model)
.arg("--output-format")
.arg("json")
.arg("--strict-mcp-config")
.env(IN_CLASSIFIER_ENV, "1");
cmd
}
pub struct ClaudeBinaryRunner;
fn claude_exit_error(
status: std::process::ExitStatus,
stdout: &[u8],
stderr: &[u8],
) -> anyhow::Error {
let cap = |b: &[u8]| {
let s = String::from_utf8_lossy(b);
let s = s.trim().to_string();
if s.chars().count() > 600 {
format!("{}…", s.chars().take(600).collect::<String>())
} else {
s
}
};
let out = cap(stdout);
let err = cap(stderr);
let detail = match (out.is_empty(), err.is_empty()) {
(true, true) => "(no output)".to_string(),
(false, true) => out,
(true, false) => err,
(false, false) => format!("{err} | stdout: {out}"),
};
anyhow!("`claude -p` exited with {status}: {detail}")
}
impl CommandRunner for ClaudeBinaryRunner {
fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
let output = base_claude_command(model)
.arg(prompt)
.output()
.context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?;
if !output.status.success() {
return Err(claude_exit_error(
output.status,
&output.stdout,
&output.stderr,
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
pub struct ClaudeBinaryStdinRunner;
impl CommandRunner for ClaudeBinaryStdinRunner {
fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
use std::io::Write;
use std::process::Stdio;
let mut child = base_claude_command(model)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?;
child
.stdin
.take()
.context("claude stdin was not captured")?
.write_all(prompt.as_bytes())
.context("failed to write prompt to claude stdin")?;
let output = child
.wait_with_output()
.context("failed to wait for `claude`")?;
if !output.status.success() {
return Err(claude_exit_error(
output.status,
&output.stdout,
&output.stderr,
));
}
Ok(String::from_utf8_lossy(&output.stdout).into_owned())
}
}
pub struct ClaudeCliClassifier {
model: String,
runner: Box<dyn CommandRunner>,
}
impl ClaudeCliClassifier {
pub fn from_env() -> Option<Self> {
if !claude_on_path() {
return None;
}
let model = std::env::var("TJ_AGENT_SDK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
Some(Self {
model,
runner: Box::new(ClaudeBinaryRunner),
})
}
pub fn with_runner(model: impl Into<String>, runner: Box<dyn CommandRunner>) -> Self {
Self {
model: model.into(),
runner,
}
}
}
#[derive(serde::Deserialize)]
struct CliEnvelope {
#[serde(default)]
is_error: bool,
#[serde(default)]
result: Option<String>,
#[serde(default)]
subtype: Option<String>,
}
impl Classifier for ClaudeCliClassifier {
fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
let prompt = crate::classifier::prompt::build(input);
let verdict = run_claude_json(self.runner.as_ref(), &self.model, &prompt)?;
super::parse_verdict(&verdict)
}
}
pub fn run_claude_json(
runner: &dyn CommandRunner,
model: &str,
prompt: &str,
) -> anyhow::Result<String> {
let stdout = runner.run(model, prompt)?;
let envelope: CliEnvelope = serde_json::from_str(stdout.trim()).with_context(|| {
format!(
"claude --output-format json wrapper parse failed; got: {}",
stdout.trim()
)
})?;
if envelope.is_error {
return Err(anyhow!(
"claude reported an error (subtype={})",
envelope.subtype.as_deref().unwrap_or("unknown")
));
}
envelope
.result
.ok_or_else(|| anyhow!("claude json wrapper had no `result` field"))
}
pub fn claude_on_path() -> bool {
Command::new("claude")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::classifier::{decide_status, CONFIDENCE_THRESHOLD};
use crate::event::{EventStatus, EventType};
struct FakeRunner {
canned: String,
seen_model: std::sync::Mutex<Option<String>>,
}
impl FakeRunner {
fn new(canned: impl Into<String>) -> Self {
Self {
canned: canned.into(),
seen_model: std::sync::Mutex::new(None),
}
}
}
impl CommandRunner for FakeRunner {
fn run(&self, model: &str, _prompt: &str) -> anyhow::Result<String> {
*self.seen_model.lock().unwrap() = Some(model.to_string());
Ok(self.canned.clone())
}
}
fn input() -> ClassifyInput {
ClassifyInput {
text: "We adopted Rust for the journal core.".into(),
author_hint: "assistant".into(),
recent_tasks: vec![],
}
}
fn envelope(result_json: &str) -> String {
serde_json::json!({
"type": "result",
"subtype": "success",
"is_error": false,
"result": result_json,
})
.to_string()
}
#[test]
fn base_command_carries_recursion_marker() {
use std::ffi::OsStr;
assert_eq!(IN_CLASSIFIER_ENV, "TJ_IN_CLASSIFIER");
let cmd = base_claude_command("claude-haiku-4-5");
let marker = cmd
.get_envs()
.any(|(k, v)| k == OsStr::new(IN_CLASSIFIER_ENV) && v == Some(OsStr::new("1")));
assert!(
marker,
"every spawned `claude -p` must set {IN_CLASSIFIER_ENV}=1 to break ingest-hook recursion"
);
}
#[test]
fn parses_canned_verdict_into_classify_output() {
let verdict = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
let c = ClaudeCliClassifier::with_runner(
DEFAULT_MODEL,
Box::new(FakeRunner::new(envelope(verdict))),
);
let out = c.classify(&input()).unwrap();
assert_eq!(out.event_type, EventType::Decision);
assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
assert!((out.confidence - 0.93).abs() < 1e-6);
assert_eq!(decide_status(out.confidence), EventStatus::Confirmed);
}
struct ArcRunner(std::sync::Arc<FakeRunner>);
impl CommandRunner for ArcRunner {
fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
self.0.run(model, prompt)
}
}
#[test]
fn pins_the_configured_model() {
let verdict = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
let captured = std::sync::Arc::new(FakeRunner::new(envelope(verdict)));
let c = ClaudeCliClassifier::with_runner(
"claude-haiku-4-5",
Box::new(ArcRunner(captured.clone())),
);
let _ = c.classify(&input()).unwrap();
assert_eq!(
captured.seen_model.lock().unwrap().as_deref(),
Some("claude-haiku-4-5"),
"classifier must pin the model it was constructed with"
);
}
#[test]
fn decide_status_at_the_0_85_threshold() {
for (conf, expect) in [
(0.85_f64, EventStatus::Confirmed),
(0.84_f64, EventStatus::Suggested),
] {
let verdict = format!(
r#"{{"event_type":"evidence","task_id_guess":null,"confidence":{conf},"evidence_strength":"strong","suggested_text":"t"}}"#
);
let c = ClaudeCliClassifier::with_runner(
DEFAULT_MODEL,
Box::new(FakeRunner::new(envelope(&verdict))),
);
let out = c.classify(&input()).unwrap();
assert!((out.confidence - conf).abs() < 1e-6);
assert_eq!(decide_status(out.confidence), expect);
assert_eq!(CONFIDENCE_THRESHOLD, 0.85);
}
}
#[test]
fn tolerates_code_fence_wrapped_verdict() {
let verdict = "```json\n{\"event_type\":\"rejection\",\"task_id_guess\":null,\"confidence\":0.88,\"evidence_strength\":null,\"suggested_text\":\"won't work\"}\n```";
let c = ClaudeCliClassifier::with_runner(
DEFAULT_MODEL,
Box::new(FakeRunner::new(envelope(verdict))),
);
let out = c.classify(&input()).unwrap();
assert_eq!(out.event_type, EventType::Rejection);
}
#[test]
fn errors_when_claude_reports_is_error() {
let canned = serde_json::json!({
"type": "result",
"subtype": "error_during_execution",
"is_error": true,
"result": null,
})
.to_string();
let c = ClaudeCliClassifier::with_runner(DEFAULT_MODEL, Box::new(FakeRunner::new(canned)));
let err = c.classify(&input()).unwrap_err();
assert!(format!("{err}").contains("error"), "got: {err}");
}
}