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
4 changes: 2 additions & 2 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@
},
"metadata": {
"description": "Task Journal — append-only reasoning chain memory for AI-coding tasks",
"version": "0.13.0"
"version": "0.13.1"
},
"plugins": [
{
"name": "task-journal",
"source": "./plugin",
"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.",
"version": "0.13.0",
"version": "0.13.1",
"author": {
"name": "Digital-Threads"
},
Expand Down
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [0.13.1] - 2026-06-12

### Fixed
- **Classifier `claude -p` recursion fork bomb.** The `ingest-hook` recursion
guard checked `TJ_IN_CLASSIFIER`, but no spawn site ever set it — so every
classifier `claude -p` (a full Claude Code instance) re-ran the user's
SessionStart hooks, including `ingest-hook`, which spawned another classifier
`claude -p`, unbounded. On machines with git-touching SessionStart hooks (or
an agent multiplexer like aimux) this also hammered git with dozens of
concurrent `git pull origin HEAD` and stray commits to `main`. Both runners
now stamp the marker via a shared `base_claude_command`; the guard and the
worker's `env_remove` reference one `tj_core` constant so the setter and
checker can't drift again; a regression test asserts the spawned command
carries the marker.

### Added
- **`dream` backfill runs on the subscription via agent-sdk (Haiku).** The
offline dream Pass A backend can now reach an LLM through the local `claude`
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.13.0"
version = "0.13.1"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion crates/tj-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name = "task-journal"
path = "src/main.rs"

[dependencies]
tj-core = { package = "task-journal-core", version = "0.13.0", path = "../tj-core" }
tj-core = { package = "task-journal-core", version = "0.13.1", path = "../tj-core" }
anyhow = { workspace = true }
clap = { workspace = true }
tracing = { workspace = true }
Expand Down
4 changes: 2 additions & 2 deletions crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1629,7 +1629,7 @@ fn main() -> Result<()> {
// subscription auth (claude-memory-0kk), so the classifier
// now sets TJ_IN_CLASSIFIER=1 in the child env and we bail
// here when we see it.
if std::env::var("TJ_IN_CLASSIFIER").is_ok() {
if std::env::var(tj_core::classifier::agent_sdk::IN_CLASSIFIER_ENV).is_ok() {
return Ok(());
}

Expand Down Expand Up @@ -3734,7 +3734,7 @@ fn spawn_classify_worker(backend: &str) -> anyhow::Result<()> {
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.env("TJ_CLASSIFIER_BUMP", "1")
.env_remove("TJ_IN_CLASSIFIER");
.env_remove(tj_core::classifier::agent_sdk::IN_CLASSIFIER_ENV);
let _child = cmd.spawn().context("spawn classify-worker")?;
// Drop child intentionally — Linux init reaps when parent exits.
Ok(())
Expand Down
61 changes: 47 additions & 14 deletions crates/tj-core/src/classifier/agent_sdk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,17 @@ use std::process::Command;
/// `TJ_AGENT_SDK_MODEL`.
pub const DEFAULT_MODEL: &str = "claude-haiku-4-5";

/// Env var stamped onto every spawned classifier `claude -p` subprocess. That
/// subprocess is a full Claude Code instance, so on startup it re-runs the
/// user's SessionStart hooks — including `task-journal ingest-hook`, which
/// would spawn yet another classifier `claude -p`, and so on: an unbounded
/// fork bomb. `ingest-hook` checks for this marker and no-ops when it is set,
/// breaking the recursion. The CLI guard and the worker's `env_remove` both
/// reference this constant so the setter and the checker can never drift
/// (which is exactly the bug that let the fork bomb through: the guard checked
/// `TJ_IN_CLASSIFIER` but no spawn site ever set it).
pub const IN_CLASSIFIER_ENV: &str = "TJ_IN_CLASSIFIER";

/// "Run the classifier command and hand back its raw stdout." The production
/// impl shells out to `claude`; tests inject a fake returning canned JSON.
pub trait CommandRunner: Send + Sync {
Expand All @@ -29,6 +40,24 @@ pub trait CommandRunner: Send + Sync {
fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String>;
}

/// Build the base `claude` invocation shared by both runners: print mode, the
/// pinned model, the JSON envelope, an isolated MCP config, and — critically —
/// the [`IN_CLASSIFIER_ENV`] recursion marker. The argv runner appends the
/// prompt as a positional arg; the stdin runner feeds it on stdin. Extracted so
/// a unit test can assert the marker is present without spawning `claude` (the
/// missing marker is exactly what let the fork bomb through before).
fn base_claude_command(model: &str) -> Command {
let mut cmd = Command::new("claude");
cmd.arg("-p")
.arg("--model")
.arg(model)
.arg("--output-format")
.arg("json")
.arg("--strict-mcp-config")
.env(IN_CLASSIFIER_ENV, "1");
cmd
}

/// Production runner: invokes the local `claude` binary in print mode, pinned
/// to the given model, asking for the JSON envelope and an isolated MCP config
/// (`--strict-mcp-config` keeps the project's own MCP servers — including this
Expand All @@ -37,14 +66,8 @@ pub struct ClaudeBinaryRunner;

impl CommandRunner for ClaudeBinaryRunner {
fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
let output = Command::new("claude")
.arg("-p")
let output = base_claude_command(model)
.arg(prompt)
.arg("--model")
.arg(model)
.arg("--output-format")
.arg("json")
.arg("--strict-mcp-config")
.output()
.context("failed to spawn `claude` (is Claude Code installed and on PATH?)")?;
if !output.status.success() {
Expand All @@ -70,13 +93,7 @@ impl CommandRunner for ClaudeBinaryStdinRunner {
fn run(&self, model: &str, prompt: &str) -> anyhow::Result<String> {
use std::io::Write;
use std::process::Stdio;
let mut child = Command::new("claude")
.arg("-p")
.arg("--model")
.arg(model)
.arg("--output-format")
.arg("json")
.arg("--strict-mcp-config")
let mut child = base_claude_command(model)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
Expand Down Expand Up @@ -240,6 +257,22 @@ mod tests {
.to_string()
}

#[test]
fn base_command_carries_recursion_marker() {
use std::ffi::OsStr;
// The tj-cli ingest-hook guard short-circuits on this exact var; if the
// const and the spawn site ever drift, the fork bomb returns.
assert_eq!(IN_CLASSIFIER_ENV, "TJ_IN_CLASSIFIER");
let cmd = base_claude_command("claude-haiku-4-5");
let marker = cmd
.get_envs()
.any(|(k, v)| k == OsStr::new(IN_CLASSIFIER_ENV) && v == Some(OsStr::new("1")));
assert!(
marker,
"every spawned `claude -p` must set {IN_CLASSIFIER_ENV}=1 to break ingest-hook recursion"
);
}

#[test]
fn parses_canned_verdict_into_classify_output() {
let verdict = r#"{"event_type":"decision","task_id_guess":"tj-x","confidence":0.93,"evidence_strength":null,"suggested_text":"Adopt Rust."}"#;
Expand Down
2 changes: 1 addition & 1 deletion crates/tj-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name = "task-journal-mcp"
path = "src/main.rs"

[dependencies]
tj-core = { package = "task-journal-core", version = "0.13.0", path = "../tj-core" }
tj-core = { package = "task-journal-core", version = "0.13.1", path = "../tj-core" }
anyhow = { workspace = true }
tokio = { workspace = true }
tracing = { workspace = true }
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "task-journal",
"version": "0.13.0",
"version": "0.13.1",
"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.",
"author": {
"name": "Mher Shahinyan"
Expand Down
Loading