Skip to content

Commit 2de0959

Browse files
Shahinyanmclaude
andcommitted
fix(classifier): strip wrapper prelude before JSON envelope (v0.2.10)
aimux and similar orchestrators prepend status lines to stdout before forwarding claude's --output-format=json envelope. v0.2.9 still fed the entire stdout to serde_json, which choked on: Auto-sync: 0 created, 0 repaired, 1 conflicts {"type":"result", ...} and routed every classified event into pending/ even though the classifier itself succeeded. Fix anchors the parse at the first `{` in stdout. Unit test classifier_strips_wrapper_prelude_before_envelope drives a fake shim that emits a prelude line followed by a real envelope. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent b1d1e18 commit 2de0959

8 files changed

Lines changed: 89 additions & 9 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,14 @@
66
},
77
"metadata": {
88
"description": "Task Journal — append-only reasoning chain memory for AI-coding tasks",
9-
"version": "0.2.9"
9+
"version": "0.2.10"
1010
},
1111
"plugins": [
1212
{
1313
"name": "task-journal",
1414
"source": "./plugin",
1515
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
16-
"version": "0.2.9",
16+
"version": "0.2.10",
1717
"author": {
1818
"name": "Digital-Threads"
1919
},

CHANGELOG.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.2.10] - 2026-05-07
11+
12+
### Fixed
13+
- Classifier now strips wrapper prelude lines from claude's stdout
14+
before parsing the JSON envelope. `aimux run` (and similar
15+
orchestrators) prepend "Auto-sync: 0 created, 0 repaired, …"-style
16+
status lines, which made `serde_json::from_str` choke on the first
17+
character. We now anchor the parse at the first `{`. One unit test
18+
(`classifier_strips_wrapper_prelude_before_envelope`) covers the
19+
shape end-to-end with a fake script that emits a prelude before
20+
the envelope.
21+
1022
## [0.2.9] - 2026-05-07
1123

1224
Critical fix: classifier path now works for users on Claude Pro/Max

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.2.9"
10+
version = "0.2.10"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.2.9", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.10", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-core/src/classifier/cli.rs

Lines changed: 70 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -88,8 +88,17 @@ impl Classifier for ClaudeCliClassifier {
8888
}
8989

9090
let stdout = String::from_utf8(output.stdout).context("claude -p stdout not UTF-8")?;
91-
let cli_result: CliResult = serde_json::from_str(stdout.trim())
92-
.with_context(|| format!("parse claude -p JSON envelope; got: {}", stdout.trim()))?;
91+
// Wrappers like `aimux run` prepend status lines to stdout
92+
// (e.g. "Auto-sync: 0 created, 0 repaired, 1 conflicts\n").
93+
// claude's JSON envelope is always a single object, so we
94+
// anchor the parse at the first `{` and ignore any prelude.
95+
let envelope = stdout
96+
.find('{')
97+
.map(|i| &stdout[i..])
98+
.unwrap_or(stdout.as_str())
99+
.trim();
100+
let cli_result: CliResult = serde_json::from_str(envelope)
101+
.with_context(|| format!("parse claude -p JSON envelope; got: {envelope}"))?;
93102

94103
if cli_result.is_error {
95104
return Err(anyhow!(
@@ -156,6 +165,65 @@ mod tests {
156165
// returns "batch file arguments are invalid" for any arg with `"` etc.
157166
// Real `claude` is a native binary, so production is unaffected; this
158167
// is purely a test-fake limitation. Skip the affected tests on Windows.
168+
/// Build a fake_claude that prepends a wrapper-style status line
169+
/// before the JSON envelope (mimics `aimux run`'s "Auto-sync: …" output).
170+
#[cfg(unix)]
171+
fn fake_claude_with_prelude(
172+
dir: &std::path::Path,
173+
prelude: &str,
174+
envelope: &str,
175+
) -> std::path::PathBuf {
176+
use std::os::unix::fs::PermissionsExt;
177+
let json_path = dir.join("fake-claude-output.json");
178+
std::fs::write(&json_path, envelope).unwrap();
179+
let path = dir.join("fake-claude-prelude.sh");
180+
let script = format!(
181+
"#!/bin/sh\necho '{}'\ncat \"{}\"\n",
182+
prelude,
183+
json_path.to_string_lossy()
184+
);
185+
std::fs::write(&path, script).unwrap();
186+
let mut perms = std::fs::metadata(&path).unwrap().permissions();
187+
perms.set_mode(0o755);
188+
std::fs::set_permissions(&path, perms).unwrap();
189+
path
190+
}
191+
192+
#[test]
193+
#[cfg(unix)]
194+
fn classifier_strips_wrapper_prelude_before_envelope() {
195+
// Reproduces aimux's "Auto-sync: 0 created, 0 repaired, 1 conflicts"
196+
// line that appears before claude's JSON envelope. The parser
197+
// must anchor at the first `{` so the prelude is ignored.
198+
let dir = tempfile::TempDir::new().unwrap();
199+
let inner = r#"{"event_type":"finding","task_id_guess":"tj-x","confidence":0.9,"evidence_strength":null,"suggested_text":"ok"}"#;
200+
let envelope = serde_json::json!({
201+
"type": "result",
202+
"subtype": "success",
203+
"is_error": false,
204+
"result": inner,
205+
});
206+
let fake = fake_claude_with_prelude(
207+
dir.path(),
208+
"Auto-sync: 0 created, 0 repaired, 1 conflicts",
209+
&envelope.to_string(),
210+
);
211+
212+
let c = ClaudeCliClassifier {
213+
command: fake.to_string_lossy().to_string(),
214+
model: "haiku".into(),
215+
};
216+
let out = c
217+
.classify(&ClassifyInput {
218+
text: "x".into(),
219+
author_hint: "user".into(),
220+
recent_tasks: vec![],
221+
})
222+
.unwrap();
223+
assert_eq!(out.event_type, EventType::Finding);
224+
assert_eq!(out.task_id_guess.as_deref(), Some("tj-x"));
225+
}
226+
159227
#[test]
160228
#[cfg_attr(
161229
windows,

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal-mcp"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.2.9", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.2.10", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
tokio = { workspace = true }
2222
tracing = { workspace = true }

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.2.9",
3+
"version": "0.2.10",
44
"description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan"

plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.2.9",
3+
"version": "0.2.10",
44
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan",

0 commit comments

Comments
 (0)