diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 39f4730..0c16a34 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -6,14 +6,14 @@ }, "metadata": { "description": "Task Journal — append-only reasoning chain memory for AI-coding tasks", - "version": "0.14.1" + "version": "0.14.2" }, "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.14.1", + "version": "0.14.2", "author": { "name": "Digital-Threads" }, diff --git a/CHANGELOG.md b/CHANGELOG.md index 9df0c04..b22cb38 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.2] - 2026-06-12 + +### Fixed +- **`install-hooks` no longer clobbers other plugins' hooks.** It used to + replace the entire `hooks` block in settings.json, wiping any co-located + third-party hooks (token-pilot, context-mode, …). It now **merges**: for each + event it touches, it strips only prior task-journal entries (idempotent + re-install) and appends its own, leaving foreign hooks and untouched events + intact. Makes re-running `install-hooks` safe on a multi-plugin setup — + needed to wire the 0.14.1 nudge without nuking everything else. + ## [0.14.1] - 2026-06-12 ### Added (reliability — no model, no cost) diff --git a/Cargo.lock b/Cargo.lock index 2128ee5..c3d35f1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2166,7 +2166,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.14.1" +version = "0.14.2" dependencies = [ "anyhow", "assert_cmd", @@ -2189,7 +2189,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.14.1" +version = "0.14.2" dependencies = [ "anyhow", "chrono", @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.14.1" +version = "0.14.2" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index b9b0cd3..2620050 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.14.1" +version = "0.14.2" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index 23fecd8..a5cab55 100644 --- a/crates/tj-cli/Cargo.toml +++ b/crates/tj-cli/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.14.1", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.14.2", path = "../tj-core" } anyhow = { workspace = true } clap = { workspace = true } tracing = { workspace = true } diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 9f87668..bc5695e 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1554,7 +1554,47 @@ fn main() -> Result<()> { ); } } - hooks_obj.insert("hooks".into(), entries); + // MERGE our entries into the existing `hooks` block — touch ONLY + // task-journal hooks, never clobber other plugins' hooks. For each + // event we (a) strip any prior task-journal entry (idempotent + // re-install) then (b) append ours, leaving foreign hooks and + // untouched events intact. + let is_tj = |c: &str| { + c.contains("task-journal ingest-hook") || c.contains("task-journal nudge") + }; + let hooks_block = hooks_obj + .entry("hooks".to_string()) + .or_insert_with(|| serde_json::json!({})); + let hooks_block = hooks_block + .as_object_mut() + .ok_or_else(|| anyhow::anyhow!("settings `hooks` is not an object"))?; + for (event, our_arr) in entries.as_object().expect("entries is an object") { + let existing = hooks_block + .entry(event.clone()) + .or_insert_with(|| serde_json::json!([])); + let existing = existing + .as_array_mut() + .ok_or_else(|| anyhow::anyhow!("hooks.{event} is not an array"))?; + for entry in existing.iter_mut() { + if let Some(inner) = entry.get_mut("hooks").and_then(|v| v.as_array_mut()) { + inner.retain(|h| { + h.get("command") + .and_then(|c| c.as_str()) + .map(|c| !is_tj(c)) + .unwrap_or(true) + }); + } + } + existing.retain(|e| { + e.get("hooks") + .and_then(|v| v.as_array()) + .map(|a| !a.is_empty()) + .unwrap_or(true) + }); + for our_entry in our_arr.as_array().expect("event entry is an array") { + existing.push(our_entry.clone()); + } + } } std::fs::write(&settings_path, serde_json::to_string_pretty(¤t)?)?; println!("{}", settings_path.display()); diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 7670546..555fb2a 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -881,6 +881,60 @@ fn install_hooks_auto_capture_wires_all_events() { } } +#[test] +fn install_hooks_merges_and_preserves_third_party_hooks() { + let dir = assert_fs::TempDir::new().unwrap(); + let claude_dir = dir.path().join(".claude"); + std::fs::create_dir_all(&claude_dir).unwrap(); + // Pre-existing foreign hooks (another plugin) on the same events we touch. + std::fs::write( + claude_dir.join("settings.json"), + serde_json::json!({ + "hooks": { + "UserPromptSubmit": [{ "matcher": "", "hooks": [ + { "type": "command", "command": "other-plugin do-thing" } + ]}], + "SessionStart": [{ "matcher": "", "hooks": [ + { "type": "command", "command": "other-plugin start" } + ]}] + } + }) + .to_string(), + ) + .unwrap(); + + let run = || { + Command::cargo_bin("task-journal") + .unwrap() + .env("HOME", dir.path()) + .args(["install-hooks", "--scope", "user"]) + .assert() + .success(); + }; + run(); + run(); // idempotent: second install must not duplicate task-journal entries + + let content = std::fs::read_to_string(claude_dir.join("settings.json")).unwrap(); + // Foreign hooks survive. + assert!( + content.contains("other-plugin do-thing"), + "must preserve a third-party UserPromptSubmit hook: {content}" + ); + assert!( + content.contains("other-plugin start"), + "must preserve a third-party SessionStart hook" + ); + // Ours got added. + assert!(content.contains("task-journal nudge")); + assert!(content.contains("task-journal ingest-hook")); + // Idempotent — exactly one nudge, not two. + assert_eq!( + content.matches("task-journal nudge").count(), + 1, + "re-install must not duplicate the nudge hook: {content}" + ); +} + #[test] fn install_hooks_is_idempotent_and_uninstall_works() { let dir = assert_fs::TempDir::new().unwrap(); diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index c789aea..6edce88 100644 --- a/crates/tj-mcp/Cargo.toml +++ b/crates/tj-mcp/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal-mcp" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.14.1", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.14.2", path = "../tj-core" } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index 53d629c..d2e60b6 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.14.1", + "version": "0.14.2", "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"