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
96fn claude_timeout() -> std::time::Duration {
101 let secs = std::env::var("TJ_CLAUDE_TIMEOUT_SECS")
102 .ok()
103 .and_then(|s| s.parse::<u64>().ok())
104 .filter(|n| *n > 0)
105 .unwrap_or(90);
106 std::time::Duration::from_secs(secs)
107}
108
109fn wait_with_timeout(
113 mut child: std::process::Child,
114 timeout: std::time::Duration,
115) -> anyhow::Result<std::process::Output> {
116 use std::io::Read;
117 let mut out_pipe = child.stdout.take();
118 let mut err_pipe = child.stderr.take();
119 let so = std::thread::spawn(move || {
120 let mut b = Vec::new();
121 if let Some(p) = out_pipe.as_mut() {
122 let _ = p.read_to_end(&mut b);
123 }
124 b
125 });
126 let se = std::thread::spawn(move || {
127 let mut b = Vec::new();
128 if let Some(p) = err_pipe.as_mut() {
129 let _ = p.read_to_end(&mut b);
130 }
131 b
132 });
133 let start = std::time::Instant::now();
134 let status = loop {
135 if let Some(status) = child.try_wait()? {
136 break status;
137 }
138 if start.elapsed() >= timeout {
139 let _ = child.kill();
140 let _ = child.wait();
141 anyhow::bail!("`claude -p` timed out after {}s", timeout.as_secs());
142 }
143 std::thread::sleep(std::time::Duration::from_millis(150));
144 };
145 Ok(std::process::Output {
146 status,
147 stdout: so.join().unwrap_or_default(),
148 stderr: se.join().unwrap_or_default(),
149 })
150}
151
152impl CommandRunner for ClaudeBinaryRunner {
153 fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
154 let child = base_claude_command(model)
155 .arg(prompt)
156 .stdout(std::process::Stdio::piped())
157 .stderr(std::process::Stdio::piped())
158 .spawn()
159 .context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?;
160 let output = wait_with_timeout(child, claude_timeout())?;
161 if !output.status.success() {
162 return Err(claude_exit_error(
163 output.status,
164 &output.stdout,
165 &output.stderr,
166 ));
167 }
168 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
169 }
170}
171
172pub struct ClaudeBinaryStdinRunner;
178
179impl CommandRunner for ClaudeBinaryStdinRunner {
180 fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
181 use std::io::Write;
182 use std::process::Stdio;
183 let mut child = base_claude_command(model)
184 .stdin(Stdio::piped())
185 .stdout(Stdio::piped())
186 .stderr(Stdio::piped())
187 .spawn()
188 .context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?;
189 child
192 .stdin
193 .take()
194 .context("claude stdin was not captured")?
195 .write_all(prompt.as_bytes())
196 .context("failed to write prompt to claude stdin")?;
197 let output = wait_with_timeout(child, claude_timeout())?;
198 if !output.status.success() {
199 return Err(claude_exit_error(
200 output.status,
201 &output.stdout,
202 &output.stderr,
203 ));
204 }
205 Ok(String::from_utf8_lossy(&output.stdout).into_owned())
206 }
207}
208
209pub struct ClaudeCliClassifier {
210 model: String,
211 runner: Box<dyn CommandRunner>,
212}
213
214impl ClaudeCliClassifier {
215 pub fn from_env() -> Option<Self> {
219 if !claude_on_path() {
220 return None;
221 }
222 let model = std::env::var("TJ_AGENT_SDK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into());
223 Some(Self {
224 model,
225 runner: Box::new(ClaudeBinaryRunner),
226 })
227 }
228
229 pub fn with_runner(model: impl Into<String>, runner: Box<dyn CommandRunner>) -> Self {
232 Self {
233 model: model.into(),
234 runner,
235 }
236 }
237}
238
239#[derive(serde::Deserialize)]
243struct CliEnvelope {
244 #[serde(default)]
245 is_error: bool,
246 #[serde(default)]
247 result: Option<String>,
248 #[serde(default)]
249 subtype: Option<String>,
250}
251
252impl Classifier for ClaudeCliClassifier {
253 fn classify(&self, input: &ClassifyInput) -> anyhow::Result<ClassifyOutput> {
254 let prompt = crate::classifier::prompt::build(input);
255 let verdict = run_claude_json(self.runner.as_ref(), &self.model, &prompt)?;
256 super::parse_verdict(&verdict)
257 }
258}
259
260pub fn run_claude_json(
265 runner: &dyn CommandRunner,
266 model: &str,
267 prompt: &str,
268) -> anyhow::Result<String> {
269 let stdout = runner.run(model, prompt)?;
270 let envelope: CliEnvelope = serde_json::from_str(stdout.trim()).with_context(|| {
271 format!(
272 "claude --output-format json wrapper parse failed; got: {}",
273 stdout.trim()
274 )
275 })?;
276 if envelope.is_error {
277 return Err(anyhow!(
278 "claude reported an error (subtype={})",
279 envelope.subtype.as_deref().unwrap_or("unknown")
280 ));
281 }
282 envelope
283 .result
284 .ok_or_else(|| anyhow!("claude json wrapper had no `result` field"))
285}
286
287pub fn claude_on_path() -> bool {
290 Command::new("claude")
291 .arg("--version")
292 .output()
293 .map(|o| o.status.success())
294 .unwrap_or(false)
295}
296
297#[cfg(test)]
298mod tests {
299 use super::*;
300 use crate::classifier::{decide_status, CONFIDENCE_THRESHOLD};
301 use crate::event::{EventStatus, EventType};
302
303 struct FakeRunner {
306 canned: String,
307 seen_model: std::sync::Mutex<Option<String>>,
308 }
309
310 impl FakeRunner {
311 fn new(canned: impl Into<String>) -> Self {
312 Self {
313 canned: canned.into(),
314 seen_model: std::sync::Mutex::new(None),
315 }
316 }
317 }
318
319 impl CommandRunner for FakeRunner {
320 fn run(&self, model: &str, _prompt: &str) -> anyhow::Result<String> {
321 *self.seen_model.lock().unwrap() = Some(model.to_string());
322 Ok(self.canned.clone())
323 }
324 }
325
326 fn input() -> ClassifyInput {
327 ClassifyInput {
328 text: "We adopted Rust for the journal core.".into(),
329 author_hint: "assistant".into(),
330 recent_tasks: vec![],
331 }
332 }
333
334 fn envelope(result_json: &str) -> String {
335 serde_json::json!({
336 "type": "result",
337 "subtype": "success",
338 "is_error": false,
339 "result": result_json,
340 })
341 .to_string()
342 }
343
344 #[test]
345 fn base_command_carries_recursion_marker() {
346 use std::ffi::OsStr;
347 assert_eq!(IN_CLASSIFIER_ENV, "TJ_IN_CLASSIFIER");
350 let cmd = base_claude_command("claude-haiku-4-5");
351 let marker = cmd
352 .get_envs()
353 .any(|(k, v)| k == OsStr::new(IN_CLASSIFIER_ENV) && v == Some(OsStr::new("1")));
354 assert!(
355 marker,
356 "every spawned `claude -p` must set {IN_CLASSIFIER_ENV}=1 to break ingest-hook recursion"
357 );
358 }
359
360 #[test]
361 fn parses_canned_verdict_into_classify_output() {
362 let verdict = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
363 let c = ClaudeCliClassifier::with_runner(
364 DEFAULT_MODEL,
365 Box::new(FakeRunner::new(envelope(verdict))),
366 );
367 let out = c.classify(&input()).unwrap();
368 assert_eq!(out.event_type, EventType::Decision);
369 assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
370 assert!((out.confidence - 0.93).abs() < 1e-6);
371 assert_eq!(decide_status(out.confidence), EventStatus::Confirmed);
373 }
374
375 struct ArcRunner(std::sync::Arc<FakeRunner>);
378 impl CommandRunner for ArcRunner {
379 fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
380 self.0.run(model, prompt)
381 }
382 }
383
384 #[test]
385 fn pins_the_configured_model() {
386 let verdict = r#"{"event_type":"finding","task_id_guess":null,"confidence":0.9,"evidence_strength":null,"suggested_text":"x"}"#;
387 let captured = std::sync::Arc::new(FakeRunner::new(envelope(verdict)));
388 let c = ClaudeCliClassifier::with_runner(
389 "claude-haiku-4-5",
390 Box::new(ArcRunner(captured.clone())),
391 );
392 let _ = c.classify(&input()).unwrap();
393 assert_eq!(
394 captured.seen_model.lock().unwrap().as_deref(),
395 Some("claude-haiku-4-5"),
396 "classifier must pin the model it was constructed with"
397 );
398 }
399
400 #[test]
401 fn decide_status_at_the_0_85_threshold() {
402 for (conf, expect) in [
403 (0.85_f64, EventStatus::Confirmed),
404 (0.84_f64, EventStatus::Suggested),
405 ] {
406 let verdict = format!(
407 r#"{{"event_type":"evidence","task_id_guess":null,"confidence":{conf},"evidence_strength":"strong","suggested_text":"t"}}"#
408 );
409 let c = ClaudeCliClassifier::with_runner(
410 DEFAULT_MODEL,
411 Box::new(FakeRunner::new(envelope(&verdict))),
412 );
413 let out = c.classify(&input()).unwrap();
414 assert!((out.confidence - conf).abs() < 1e-6);
415 assert_eq!(decide_status(out.confidence), expect);
416 assert_eq!(CONFIDENCE_THRESHOLD, 0.85);
417 }
418 }
419
420 #[test]
421 fn tolerates_code_fence_wrapped_verdict() {
422 let verdict = "```json\n{\"event_type\":\"rejection\",\"task_id_guess\":null,\"confidence\":0.88,\"evidence_strength\":null,\"suggested_text\":\"won't work\"}\n```";
423 let c = ClaudeCliClassifier::with_runner(
424 DEFAULT_MODEL,
425 Box::new(FakeRunner::new(envelope(verdict))),
426 );
427 let out = c.classify(&input()).unwrap();
428 assert_eq!(out.event_type, EventType::Rejection);
429 }
430
431 #[test]
432 fn errors_when_claude_reports_is_error() {
433 let canned = serde_json::json!({
434 "type": "result",
435 "subtype": "error_during_execution",
436 "is_error": true,
437 "result": null,
438 })
439 .to_string();
440 let c = ClaudeCliClassifier::with_runner(DEFAULT_MODEL, Box::new(FakeRunner::new(canned)));
441 let err = c.classify(&input()).unwrap_err();
442 assert!(format!("{err}").contains("error"), "got: {err}");
443 }
444}