1use super::{Classifier, ClassifyInput, ClassifyOutput};
16use anyhow::{anyhow, Context};
17use std::process::Command;
18
19pub const DEFAULT_MODEL: &str = "claude-haiku-4-5";
23
24pub const IN_CLASSIFIER_ENV: &str = "TJ_IN_CLASSIFIER";
34
35pub trait CommandRunner: Send + Sync {
38 fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String>;
41}
42
43fn base_claude_command(model: &str) -> Command {
50 let mut cmd = Command::new("claude");
51 cmd.arg("-p")
52 .arg("--model")
53 .arg(model)
54 .arg("--output-format")
55 .arg("json")
56 .arg("--strict-mcp-config")
57 .env(IN_CLASSIFIER_ENV, "1");
58 cmd
59}
60
61pub struct ClaudeBinaryRunner;
66
67fn claude_exit_error(
72 status: std::process::ExitStatus,
73 stdout: &[u8],
74 stderr: &[u8],
75) -> anyhow::Error {
76 let cap = |b: &[u8]| {
77 let s = String::from_utf8_lossy(b);
78 let s = s.trim().to_string();
79 if s.chars().count() > 600 {
80 format!("{}…", s.chars().take(600).collect::<String>())
81 } else {
82 s
83 }
84 };
85 let out = cap(stdout);
86 let err = cap(stderr);
87 let detail = match (out.is_empty(), err.is_empty()) {
88 (true, true) => "(no output)".to_string(),
89 (false, true) => out,
90 (true, false) => err,
91 (false, false) => format!("{err} | stdout: {out}"),
92 };
93 anyhow!("`claude -p` exited with {status}: {detail}")
94}
95
96impl CommandRunner for ClaudeBinaryRunner {
97 fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
98 let output = base_claude_command(model)
99 .arg(prompt)
100 .output()
101 .context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?;
102 if !output.status.success() {
103 return Err(claude_exit_error(
104 output.status,
105 &output.stdout,
106 &output.stderr,
107 ));
108 }
109 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
110 }
111}
112
113pub struct ClaudeBinaryStdinRunner;
119
120impl CommandRunner for ClaudeBinaryStdinRunner {
121 fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
122 use std::io::Write;
123 use std::process::Stdio;
124 let mut child = base_claude_command(model)
125 .stdin(Stdio::piped())
126 .stdout(Stdio::piped())
127 .stderr(Stdio::piped())
128 .spawn()
129 .context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?;
130 child
133 .stdin
134 .take()
135 .context("claude stdin was not captured")?
136 .write_all(prompt.as_bytes())
137 .context("failed to write prompt to claude stdin")?;
138 let output = child
139 .wait_with_output()
140 .context("failed to wait for `claude`")?;
141 if !output.status.success() {
142 return Err(claude_exit_error(
143 output.status,
144 &output.stdout,
145 &output.stderr,
146 ));
147 }
148 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
149 }
150}
151
152pub struct ClaudeCliClassifier {
153 model: String,
154 runner: Box<dyn CommandRunner>,
155}
156
157impl ClaudeCliClassifier {
158 pub fn from_env() -> Option<Self> {
162 if !claude_on_path() {
163 return None;
164 }
165 let model = std::env::var("TJ_AGENT_SDK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
166 Some(Self {
167 model,
168 runner: Box::new(ClaudeBinaryRunner),
169 })
170 }
171
172 pub fn with_runner(model: impl Into<String>, runner: Box<dyn CommandRunner>) -> Self {
175 Self {
176 model: model.into(),
177 runner,
178 }
179 }
180}
181
182#[derive(serde::Deserialize)]
186struct CliEnvelope {
187 #[serde(default)]
188 is_error: bool,
189 #[serde(default)]
190 result: Option<String>,
191 #[serde(default)]
192 subtype: Option<String>,
193}
194
195impl Classifier for ClaudeCliClassifier {
196 fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
197 let prompt = crate::classifier::prompt::build(input);
198 let verdict = run_claude_json(self.runner.as_ref(), &self.model, &prompt)?;
199 super::parse_verdict(&verdict)
200 }
201}
202
203pub fn run_claude_json(
208 runner: &dyn CommandRunner,
209 model: &str,
210 prompt: &str,
211) -> anyhow::Result<String> {
212 let stdout = runner.run(model, prompt)?;
213 let envelope: CliEnvelope = serde_json::from_str(stdout.trim()).with_context(|| {
214 format!(
215 "claude --output-format json wrapper parse failed; got: {}",
216 stdout.trim()
217 )
218 })?;
219 if envelope.is_error {
220 return Err(anyhow!(
221 "claude reported an error (subtype={})",
222 envelope.subtype.as_deref().unwrap_or("unknown")
223 ));
224 }
225 envelope
226 .result
227 .ok_or_else(|| anyhow!("claude json wrapper had no `result` field"))
228}
229
230pub fn claude_on_path() -> bool {
233 Command::new("claude")
234 .arg("--version")
235 .output()
236 .map(|o| o.status.success())
237 .unwrap_or(false)
238}
239
240#[cfg(test)]
241mod tests {
242 use super::*;
243 use crate::classifier::{decide_status, CONFIDENCE_THRESHOLD};
244 use crate::event::{EventStatus, EventType};
245
246 struct FakeRunner {
249 canned: String,
250 seen_model: std::sync::Mutex<Option<String>>,
251 }
252
253 impl FakeRunner {
254 fn new(canned: impl Into<String>) -> Self {
255 Self {
256 canned: canned.into(),
257 seen_model: std::sync::Mutex::new(None),
258 }
259 }
260 }
261
262 impl CommandRunner for FakeRunner {
263 fn run(&self, model: &str, _prompt: &str) -> anyhow::Result<String> {
264 *self.seen_model.lock().unwrap() = Some(model.to_string());
265 Ok(self.canned.clone())
266 }
267 }
268
269 fn input() -> ClassifyInput {
270 ClassifyInput {
271 text: "We adopted Rust for the journal core.".into(),
272 author_hint: "assistant".into(),
273 recent_tasks: vec![],
274 }
275 }
276
277 fn envelope(result_json: &str) -> String {
278 serde_json::json!({
279 "type": "result",
280 "subtype": "success",
281 "is_error": false,
282 "result": result_json,
283 })
284 .to_string()
285 }
286
287 #[test]
288 fn base_command_carries_recursion_marker() {
289 use std::ffi::OsStr;
290 assert_eq!(IN_CLASSIFIER_ENV, "TJ_IN_CLASSIFIER");
293 let cmd = base_claude_command("claude-haiku-4-5");
294 let marker = cmd
295 .get_envs()
296 .any(|(k, v)| k == OsStr::new(IN_CLASSIFIER_ENV) && v == Some(OsStr::new("1")));
297 assert!(
298 marker,
299 "every spawned `claude -p` must set {IN_CLASSIFIER_ENV}=1 to break ingest-hook recursion"
300 );
301 }
302
303 #[test]
304 fn parses_canned_verdict_into_classify_output() {
305 let verdict = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
306 let c = ClaudeCliClassifier::with_runner(
307 DEFAULT_MODEL,
308 Box::new(FakeRunner::new(envelope(verdict))),
309 );
310 let out = c.classify(&input()).unwrap();
311 assert_eq!(out.event_type, EventType::Decision);
312 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
313 assert!((out.confidence - 0.93).abs() < 1e-6);
314 assert_eq!(decide_status(out.confidence), EventStatus::Confirmed);
316 }
317
318 struct ArcRunner(std::sync::Arc<FakeRunner>);
321 impl CommandRunner for ArcRunner {
322 fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
323 self.0.run(model, prompt)
324 }
325 }
326
327 #[test]
328 fn pins_the_configured_model() {
329 let verdict = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
330 let captured = std::sync::Arc::new(FakeRunner::new(envelope(verdict)));
331 let c = ClaudeCliClassifier::with_runner(
332 "claude-haiku-4-5",
333 Box::new(ArcRunner(captured.clone())),
334 );
335 let _ = c.classify(&input()).unwrap();
336 assert_eq!(
337 captured.seen_model.lock().unwrap().as_deref(),
338 Some("claude-haiku-4-5"),
339 "classifier must pin the model it was constructed with"
340 );
341 }
342
343 #[test]
344 fn decide_status_at_the_0_85_threshold() {
345 for (conf, expect) in [
346 (0.85_f64, EventStatus::Confirmed),
347 (0.84_f64, EventStatus::Suggested),
348 ] {
349 let verdict = format!(
350 r#"{{"event_type":"evidence","task_id_guess":null,"confidence":{conf},"evidence_strength":"strong","suggested_text":"t"}}"#
351 );
352 let c = ClaudeCliClassifier::with_runner(
353 DEFAULT_MODEL,
354 Box::new(FakeRunner::new(envelope(&verdict))),
355 );
356 let out = c.classify(&input()).unwrap();
357 assert!((out.confidence - conf).abs() < 1e-6);
358 assert_eq!(decide_status(out.confidence), expect);
359 assert_eq!(CONFIDENCE_THRESHOLD, 0.85);
360 }
361 }
362
363 #[test]
364 fn tolerates_code_fence_wrapped_verdict() {
365 let verdict = "```json\n{\"event_type\":\"rejection\",\"task_id_guess\":null,\"confidence\":0.88,\"evidence_strength\":null,\"suggested_text\":\"won't work\"}\n```";
366 let c = ClaudeCliClassifier::with_runner(
367 DEFAULT_MODEL,
368 Box::new(FakeRunner::new(envelope(verdict))),
369 );
370 let out = c.classify(&input()).unwrap();
371 assert_eq!(out.event_type, EventType::Rejection);
372 }
373
374 #[test]
375 fn errors_when_claude_reports_is_error() {
376 let canned = serde_json::json!({
377 "type": "result",
378 "subtype": "error_during_execution",
379 "is_error": true,
380 "result": null,
381 })
382 .to_string();
383 let c = ClaudeCliClassifier::with_runner(DEFAULT_MODEL, Box::new(FakeRunner::new(canned)));
384 let err = c.classify(&input()).unwrap_err();
385 assert!(format!("{err}").contains("error"), "got: {err}");
386 }
387}