Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 13 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.26.3"
version = "0.26.4"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
39 changes: 38 additions & 1 deletion crates/tj-core/src/classifier/agent_sdk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>;

/// 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 <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
Expand Down Expand Up @@ -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<String> {
use std::io::Write;
use std::process::Stdio;
Expand Down Expand Up @@ -222,6 +236,14 @@ pub struct ClaudeCliClassifier {
runner: Box<dyn CommandRunner>,
}

/// 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<dyn CommandRunner> {
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
Expand All @@ -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(),
})
}

Expand Down Expand Up @@ -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());
}
}
Loading