diff --git a/CHANGELOG.md b/CHANGELOG.md index c33ab91..6ed0f93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,19 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.26.4] - 2026-06-17 + +### Fixed +- **Auto-classifier no longer fails every chunk.** The `claude -p` classifier + passed the prompt as a positional arg right after `--disallowed-tools`, which + the current `claude` CLI parses greedily — it swallowed the prompt words as + bogus deny-rules (`Permission deny rule "You" matches no known tool`) and + failed every classification, silently piling chunks into the pending queue. + The classifier now feeds the prompt on **stdin** (like the `complete`/enrich + and dream backends already did), so chunks classify instead of dead-lettering. + Run `task-journal pending retry` once to drain a backlog accumulated by the + old behavior. + ## [0.26.3] - 2026-06-16 ### Added diff --git a/Cargo.lock b/Cargo.lock index 9ded7be..26c8a23 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2516,7 +2516,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.26.3" +version = "0.26.4" dependencies = [ "anyhow", "assert_cmd", @@ -2540,7 +2540,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.26.3" +version = "0.26.4" dependencies = [ "anyhow", "chrono", @@ -2565,7 +2565,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.26.3" +version = "0.26.4" dependencies = [ "anyhow", "chrono", diff --git a/Cargo.toml b/Cargo.toml index 67cdc7b..aef600f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.26.3" +version = "0.26.4" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-core/src/classifier/agent_sdk.rs b/crates/tj-core/src/classifier/agent_sdk.rs index 5971ab4..5d6a475 100644 --- a/crates/tj-core/src/classifier/agent_sdk.rs +++ b/crates/tj-core/src/classifier/agent_sdk.rs @@ -38,6 +38,16 @@ pub trait CommandRunner: Send + Sync { /// Run the classification for `prompt` against `model`, returning the raw /// stdout (the `--output-format json` wrapper) on success. fn run(&self, model: &str, prompt: &str) -> anyhow::Result; + + /// True when the runner feeds the prompt on **stdin** rather than as a + /// positional argv arg. The classifier MUST use a stdin runner: the base + /// command ends with `--disallowed-tools `, which the current + /// `claude` CLI parses greedily — a positional prompt right after it is + /// swallowed as bogus deny-rules (`Permission deny rule "You" matches no + /// known tool`), failing every classification. Defaults to false. + fn feeds_prompt_on_stdin(&self) -> bool { + false + } } /// Build the base `claude` invocation shared by both runners: print mode, the @@ -188,6 +198,10 @@ impl CommandRunner for ClaudeBinaryRunner { pub struct ClaudeBinaryStdinRunner; impl CommandRunner for ClaudeBinaryStdinRunner { + fn feeds_prompt_on_stdin(&self) -> bool { + true + } + fn run(&self, model: &str, prompt: &str) -> anyhow::Result { use std::io::Write; use std::process::Stdio; @@ -222,6 +236,14 @@ pub struct ClaudeCliClassifier { runner: Box, } +/// The runner the production classifier uses. MUST feed the prompt on stdin — +/// see [`CommandRunner::feeds_prompt_on_stdin`] for why a positional prompt is +/// silently eaten by `--disallowed-tools`. Extracted so a unit test can lock +/// this choice without spawning `claude`. +fn default_runner() -> Box { + Box::new(ClaudeBinaryStdinRunner) +} + impl ClaudeCliClassifier { /// Build from environment. Returns `None` unless a `claude` binary is on /// PATH (probed with `claude --version`) — the caller then falls through to @@ -233,7 +255,7 @@ impl ClaudeCliClassifier { let model = std::env::var("TJ_AGENT_SDK_MODEL").unwrap_or_else(|_| DEFAULT_MODEL.into()); Some(Self { model, - runner: Box::new(ClaudeBinaryRunner), + runner: default_runner(), }) } @@ -488,4 +510,19 @@ mod tests { let err = c.classify(&input()).unwrap_err(); assert!(format!("{err}").contains("error"), "got: {err}"); } + + /// Regression: the production classifier MUST feed the prompt on stdin. With + /// the argv runner the prompt lands right after `--disallowed-tools` and the + /// current `claude` CLI swallows it as bogus deny-rules, failing every + /// classification (the "139 pending" backlog). Lock the choice here. + #[test] + fn production_runner_feeds_prompt_on_stdin() { + assert!( + default_runner().feeds_prompt_on_stdin(), + "classifier prompt must go on stdin, not as an argv positional" + ); + // the argv runner is the one that collides — keep the contrast explicit + assert!(!ClaudeBinaryRunner.feeds_prompt_on_stdin()); + assert!(ClaudeBinaryStdinRunner.feeds_prompt_on_stdin()); + } }