From 1235a7b9472c924dd5541b99bce7be5975712b3f Mon Sep 17 00:00:00 2001 From: lazy Date: Tue, 15 Sep 2026 18:14:11 -0400 Subject: [PATCH] Fix native lifecycle hooks in uninitialized worktrees --- .claude-plugin/marketplace.json | 2 +- Cargo.lock | 6 +- Cargo.toml | 2 +- README.md | 5 + .../src/activation/AGENTS.md | 4 +- .../src/activation/bridge.rs | 226 ++++++++++++++++- .../src/activation/lifecycle.rs | 12 +- crates/tree-ring-memory-cli/src/main.rs | 5 + .../tests/harness_activation_acceptance.rs | 229 ++++++++++++++++++ docs/protocol/harness-activation.md | 8 + plugins/AGENTS.md | 2 +- .../.claude-plugin/plugin.json | 2 +- .../.codex-plugin/plugin.json | 2 +- plugins/tree-ring-memory/README.md | 34 ++- plugins/tree-ring-memory/hooks/claude-hook.sh | 6 + plugins/tree-ring-memory/hooks/codex-hook.sh | 116 ++++++++- .../.codex-plugin/plugin.json | 2 +- scripts/validate-plugin-packages.py | 199 ++++++++++++++- 18 files changed, 825 insertions(+), 37 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 242f9e1..1f79c9c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -5,7 +5,7 @@ "url": "https://github.com/TerminallyLazy" }, "description": "Claude Code marketplace for Tree Ring Memory v0.15 verified bootstrap, lifecycle recall, strict automatic capture, and receipt-backed harness readiness.", - "version": "0.3.6", + "version": "0.3.7", "plugins": [ { "name": "tree-ring-memory", diff --git a/Cargo.lock b/Cargo.lock index 8b818d7..497d3da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1690,7 +1690,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tree-ring-memory-cli" -version = "0.15.12" +version = "0.15.13" dependencies = [ "chrono", "clap", @@ -1709,7 +1709,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-core" -version = "0.15.12" +version = "0.15.13" dependencies = [ "chrono", "libc", @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-sqlite" -version = "0.15.12" +version = "0.15.13" dependencies = [ "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index 3413bca..aa84c1e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.15.12" +version = "0.15.13" edition = "2021" license = "MIT" authors = ["TerminallyLazy"] diff --git a/README.md b/README.md index b9cab43..a0040c4 100644 --- a/README.md +++ b/README.md @@ -114,6 +114,11 @@ hooks for project-local installs; see the The current public-directory upload also includes native Codex lifecycle hooks; ordinary Chat hosts without the Codex hook runtime remain guidance-only. +CLI 0.15.13 and the Codex 0.3.9 / Claude 0.3.7 plugins quietly skip lifecycle +hooks in uninitialized projects and linked worktrees. Each checkout keeps its +own memory root; inherited hooks never initialize it or reuse another +checkout's store. Existing roots with broken activation still report errors. + Default `init` creates the canonical project-local store and configures maintained adapters where new project-local bridge and manifest entries can be created safely. It does not require copying a skill or manually running diff --git a/crates/tree-ring-memory-cli/src/activation/AGENTS.md b/crates/tree-ring-memory-cli/src/activation/AGENTS.md index 03a7059..9517e8d 100644 --- a/crates/tree-ring-memory-cli/src/activation/AGENTS.md +++ b/crates/tree-ring-memory-cli/src/activation/AGENTS.md @@ -10,7 +10,7 @@ Adapter detection, bridge filesystem operations, manifests, lifecycle parsing, p ## Local Contracts -Use SessionStart/SubagentStart for recall and Stop/SubagentStop for bounded agent capture. Normalize equivalent local paths without following symlinks; retain descriptor-relative no-follow access and create-only publication. Missing activation records need review; only a fresh matching receipt establishes active status. +Use SessionStart/SubagentStart for recall and Stop/SubagentStop for bounded agent capture. Normalize equivalent local paths without following symlinks; retain descriptor-relative no-follow access and create-only publication. Lifecycle hooks may skip only a genuinely absent project-local .tree-ring root after input and project-path validation. Existing invalid roots or missing activation records remain errors; only a fresh matching receipt establishes active status. Generated hook guards do not apply to explicit preflight or capture commands. ## Work Guidance @@ -21,6 +21,8 @@ runtime and minor series. Coordinate the core allowlist and plugin descriptor before publishing either release; verify the actual pair with CLI activation, preflight, and receipt-backed status. A passing version probe alone is insufficient. +Recognize earlier Claude handler bundles only by exact recorded ownership, commands, and generated entry shape. Preserve custom handlers and settings; bridge reconciliation still follows create-only publication and cannot silently replace existing hooks or manifests. + When creating root AGENTS.md, record ownership of only the marked Tree Ring block so surrounding project instructions remain editable. Preserve legacy complete-file ownership until explicitly reconciled; never migrate an existing activation manifest automatically. ## Verification diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index ea0e406..f495597 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -465,6 +465,20 @@ impl ProjectFs { Ok(project_fs) } + /// Only a genuinely absent project-local root is an inactive lifecycle hook. + /// The pinned project descriptor preserves existing path-alias semantics; + /// O_NOFOLLOW on the child keeps redirected or broken installations errors. + pub(crate) fn lifecycle_memory_root_absent(&self) -> Result { + self.ensure_root_binding()?; + let absent = match open_child_directory(&self.root, OsStr::new(".tree-ring")) { + Ok(_) => false, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => return Err(io_error(&self.project_root.join(".tree-ring"), error)), + }; + self.ensure_root_binding()?; + Ok(absent) + } + pub(crate) fn lock_manifest(&self) -> Result { self.ensure_root_binding()?; let file = self @@ -1167,6 +1181,13 @@ impl ProjectFs { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } + pub(crate) fn lifecycle_memory_root_absent(&self) -> Result { + Err( + "lifecycle root inspection requires descriptor-relative no-follow filesystem support" + .to_string(), + ) + } + pub(crate) fn lock_manifest(&self) -> Result { Err("bridge mutation requires descriptor-relative no-follow filesystem support".to_string()) } @@ -1544,6 +1565,12 @@ pub fn validate_isolated_preflight_roots( .map_err(|_| "isolated preflight roots are invalid".to_string()) } +/// Checks inactivity for lifecycle hooks without creating a store or suppressing +/// invalid existing roots. Explicit preflight and capture do not use this path. +pub fn lifecycle_memory_root_absent(project: &ActivationProject) -> Result { + ProjectFs::open(project)?.lifecycle_memory_root_absent() +} + /// Reads the init manifest through the pinned project descriptor. A missing /// manifest is returned as `None`; callers may then construct the first /// in-memory identity for creation-only batch publication. @@ -2004,8 +2031,14 @@ fn prepare_claude_settings( let before = project_fs.read_optional(&write.path)?; let path = relative_string(&write.path)?; let handler_hash = sha256(&serde_json::to_vec(&claude_handlers()).map_err(json_error)?); - let legacy_handler_hash = - sha256(&serde_json::to_vec(&legacy_claude_handlers()).map_err(json_error)?); + let legacy_handler_hashes = [legacy_claude_handlers(), previous_v4_claude_handlers()] + .iter() + .map(|handlers| { + serde_json::to_vec(handlers) + .map(|bytes| sha256(&bytes)) + .map_err(json_error) + }) + .collect::, _>>()?; if before.is_none() { if owned_files.iter().any(|owned| owned.path == path) || managed_blocks @@ -2068,7 +2101,7 @@ fn prepare_claude_settings( let owns_legacy_bundle = managed_blocks.iter().any(|owned| { owned.path == path && owned.block_id == write.block_id - && owned.sha256 == legacy_handler_hash + && legacy_handler_hashes.contains(&owned.sha256) }); match state { ClaudeHandlerState::Conflict => { @@ -2381,6 +2414,34 @@ fn legacy_claude_handlers() -> Vec<(&'static str, Value)> { ] } +// Freeze the exact pre-absence-guard v4 bundle; ownership checks must never +// adopt commands that merely resemble a previous generated template. +fn previous_v4_claude_handlers() -> Vec<(&'static str, Value)> { + let command = r#"project_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"; tree_ring="$project_root/.tree-ring/bin/tree-ring"; if [ ! -x "$tree_ring" ]; then tree_ring=tree-ring; fi; exec "$tree_ring" --root "$project_root/.tree-ring" integrations hook --harness claude-code --input-json-stdin"#; + [ + ("SessionStart", "Tree Ring Memory managed lifecycle v4"), + ( + "SubagentStart", + "Tree Ring Memory managed subagent lifecycle v4", + ), + ("Stop", "Tree Ring Memory managed capture checkpoint v4"), + ( + "SubagentStop", + "Tree Ring Memory managed subagent capture checkpoint v4", + ), + ] + .into_iter() + .map(|(event, description)| { + ( + event, + json!({ + "type": "command", "command": command, "description": description, "timeout": 10 + }), + ) + }) + .collect() +} + fn claude_handlers() -> Vec<(&'static str, Value)> { vec![ ("SessionStart", claude_handler()), @@ -2499,16 +2560,41 @@ fn insert_claude_handlers(root: &mut Map) -> Result<(), String> { } fn replace_legacy_claude_handlers(root: &mut Map) -> Result { - let legacy = legacy_claude_handlers(); + for legacy in [legacy_claude_handlers(), previous_v4_claude_handlers()] { + // Failed matches must not partially remove handlers from the caller. + let mut candidate = root.clone(); + if replace_exact_claude_handlers(&mut candidate, &legacy)? { + *root = candidate; + return Ok(true); + } + } + Ok(false) +} + +fn replace_exact_claude_handlers( + root: &mut Map, + legacy: &[(&str, Value)], +) -> Result { let Some(hooks) = root.get_mut("hooks").and_then(Value::as_object_mut) else { return Ok(false); }; let mut removed = 0usize; - for (event, expected_handler) in &legacy { + for (event, expected_handler) in legacy { let Some(entries) = hooks.get_mut(*event).and_then(Value::as_array_mut) else { return Ok(false); }; + let mut event_removed = 0usize; for entry in entries.iter_mut() { + let contains_expected = entry + .get("hooks") + .and_then(Value::as_array) + .is_some_and(|handlers| handlers.contains(expected_handler)); + if contains_expected + && (entry.get("matcher").and_then(Value::as_str) != Some("") + || entry.as_object().is_none_or(|object| object.len() != 2)) + { + return Ok(false); + } let Some(handlers) = entry.get_mut("hooks").and_then(Value::as_array_mut) else { continue; }; @@ -2516,10 +2602,14 @@ fn replace_legacy_claude_handlers(root: &mut Map) -> Result) -> Result { + handlers[0]["command"] = json!("echo custom tree-ring --harness claude-code") + } + "duplicate" => handlers.push(handlers[0].clone()), + "missing" => { + handlers.clear(); + } + "matcher" | "metadata" => {} + _ => unreachable!(), + } + let before = root.clone(); + assert!(!replace_legacy_claude_handlers(&mut root).unwrap()); + assert_eq!(root, before); + } + } + #[test] fn exact_legacy_claude_bundle_upgrades_without_touching_user_settings() { let mut root = json!({ diff --git a/crates/tree-ring-memory-cli/src/activation/lifecycle.rs b/crates/tree-ring-memory-cli/src/activation/lifecycle.rs index 3804183..daf4d22 100644 --- a/crates/tree-ring-memory-cli/src/activation/lifecycle.rs +++ b/crates/tree-ring-memory-cli/src/activation/lifecycle.rs @@ -11,12 +11,17 @@ use uuid::Uuid; const MAX_HOOK_INPUT_BYTES: usize = 1024 * 1024; -// Shared by generated hooks and their capture instructions. GUI hosts need not -// inherit a shell PATH containing the recommended project-local installation. +// Capture instructions retain strict behavior if the store disappears. GUI hosts +// need not inherit a shell PATH containing the project-local installation. pub(crate) const PROJECT_RUNTIME: &str = r#"project_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"; tree_ring="$project_root/.tree-ring/bin/tree-ring"; if [ ! -x "$tree_ring" ]; then tree_ring=tree-ring; fi"#; pub(crate) fn lifecycle_command(harness: &str) -> String { - format!("{PROJECT_RUNTIME}; exec \"$tree_ring\" --root \"$project_root/.tree-ring\" integrations hook --harness {harness} --input-json-stdin") + let guarded_runtime = PROJECT_RUNTIME.replacen( + "; tree_ring=", + "; if [ ! -e \"$project_root/.tree-ring\" ] && [ ! -L \"$project_root/.tree-ring\" ]; then exit 0; fi; tree_ring=", + 1, + ); + format!("{guarded_runtime}; exec \"$tree_ring\" --root \"$project_root/.tree-ring\" integrations hook --harness {harness} --input-json-stdin") } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -312,6 +317,7 @@ mod tests { assert!(rendered.contains("\"decision\":\"block\"")); assert!(rendered.contains(".tree-ring/bin/tree-ring")); assert!(rendered.contains(" capture ")); + assert!(!rendered.contains("then exit 0")); assert!(!rendered.contains("--scope")); assert!(!rendered.contains(private_output)); assert!(!rendered.contains("transcript.jsonl")); diff --git a/crates/tree-ring-memory-cli/src/main.rs b/crates/tree-ring-memory-cli/src/main.rs index 66987b3..b50aa96 100644 --- a/crates/tree-ring-memory-cli/src/main.rs +++ b/crates/tree-ring-memory-cli/src/main.rs @@ -757,6 +757,11 @@ fn run(cli: Cli) -> Result<(), String> { } let project = activation::adapters::ActivationProject::from_memory_root(cli.root.clone())?; let request = activation::parse_lifecycle_hook(&project, harness, &input)?; + // Inherited hooks also run in linked worktrees that have never opted in. + // Validate the request and local path first; only an absent root may skip. + if activation::bridge::lifecycle_memory_root_absent(&project)? { + return Ok(()); + } let manifest = activation::load_manifest(&cli.root)?; ensure_manifest_preflight_ready(&manifest, harness, false)?; if let Some(preflight) = request.preflight { diff --git a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs index 4a5fe73..66d0700 100644 --- a/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs +++ b/crates/tree-ring-memory-cli/tests/harness_activation_acceptance.rs @@ -37,6 +37,235 @@ const SEEDED_MEMORY: &str = const RAW_TASK_HINT: &str = "fixture project startup constraints"; const CAPABILITY_SENTINEL: &str = "fixture-coordinator-capability-must-not-persist"; +const HOOK_EVENTS: [&str; 4] = ["SessionStart", "SubagentStart", "Stop", "SubagentStop"]; + +fn lifecycle_input(event: &str, cwd: &Path) -> Value { + json!({ + "hook_event_name": event, "cwd": cwd, "session_id": "worktree-session", + "agent_id": "worktree-worker", "agent_type": "reviewer", "stop_hook_active": false + }) +} + +fn hook_output(mut command: Command, input: &Value) -> Output { + for (key, _) in std::env::vars_os() { + if key.to_string_lossy().starts_with("TREE_RING_") { + command.env_remove(key); + } + } + let mut child = command + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .unwrap(); + serde_json::to_writer(child.stdin.take().unwrap(), input).unwrap(); + child.wait_with_output().unwrap() +} + +#[test] +fn inherited_old_and_new_hooks_skip_uninitialized_linked_worktrees_without_touching_main_store() { + let temp = tempdir().unwrap(); + let project = temp.path().join("Main Project With Spaces"); + let worktree = temp.path().join("Linked Worktree With Spaces"); + for directory in [".codex", ".claude"] { + fs::create_dir_all(project.join(directory)).unwrap(); + } + let git = |args: &[&OsStr]| { + let output = Command::new("git") + .current_dir(&project) + .args(args) + .output() + .unwrap(); + assert_success("git fixture", &output); + }; + git(&[OsStr::new("init"), OsStr::new("--quiet")]); + git(&[ + OsStr::new("-c"), + OsStr::new("user.name=Fixture"), + OsStr::new("-c"), + OsStr::new("user.email=fixture@example.invalid"), + OsStr::new("-c"), + OsStr::new("commit.gpgsign=false"), + OsStr::new("commit"), + OsStr::new("--allow-empty"), + OsStr::new("-m"), + OsStr::new("fixture"), + ]); + git(&[ + OsStr::new("worktree"), + OsStr::new("add"), + OsStr::new("--detach"), + worktree.as_os_str(), + ]); + fs::create_dir_all(worktree.join("src/Nested Folder")).unwrap(); + let initialized = Command::new(env!("CARGO_BIN_EXE_tree-ring")) + .current_dir(&project) + .env("PATH", "/usr/bin:/bin") + .env("HOME", temp.path().join("fixture-home")) + .args(["--json", "welcome", "--init", "--no-animation"]) + .output() + .unwrap(); + assert_success("main fixture init", &initialized); + let main_snapshot = ["memory.sqlite", "activation.json"].map(|name| { + ( + name, + fs::read(project.join(".tree-ring").join(name)).unwrap(), + ) + }); + let runtime_path = format!( + "{}:/usr/bin:/bin", + Path::new(env!("CARGO_BIN_EXE_tree-ring")) + .parent() + .unwrap() + .display() + ); + for (harness, file) in [ + ("codex", ".codex/hooks.json"), + ("claude-code", ".claude/settings.json"), + ] { + let hooks: Value = serde_json::from_slice(&fs::read(project.join(file)).unwrap()).unwrap(); + // Frozen released v4 command: the upgraded binary must handle inherited old hooks too. + let old = format!( + r#"project_root="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"; tree_ring="$project_root/.tree-ring/bin/tree-ring"; if [ ! -x "$tree_ring" ]; then tree_ring=tree-ring; fi; exec "$tree_ring" --root "$project_root/.tree-ring" integrations hook --harness {harness} --input-json-stdin"# + ); + for event in HOOK_EVENTS { + let new = hooks["hooks"][event][0]["hooks"][0]["command"] + .as_str() + .unwrap(); + for cwd in [&worktree, &worktree.join("src/Nested Folder")] { + // New commands need no installed CLI. Old commands use only the upgraded PATH CLI. + for (command, path) in [ + (new, "/usr/bin:/bin"), + (old.as_str(), runtime_path.as_str()), + ] { + let mut shell = Command::new("/bin/sh"); + shell + .current_dir(cwd) + .env("PATH", path) + .args(["-c", command]); + let output = hook_output(shell, &lifecycle_input(event, cwd)); + assert_success("inherited worktree hook", &output); + assert!(output.stdout.is_empty()); + assert!(output.stderr.is_empty()); + } + } + } + } + assert!(!worktree.join(".tree-ring").exists()); + assert!(!worktree.join("src/Nested Folder/.tree-ring").exists()); + for (name, bytes) in main_snapshot { + assert_eq!( + fs::read(project.join(".tree-ring").join(name)).unwrap(), + bytes + ); + } + assert!(!project.join(".tree-ring/activation/receipts").exists()); +} + +#[test] +fn lifecycle_skip_keeps_invalid_existing_roots_and_activation_visible() { + let temp = tempdir().unwrap(); + for shape in [ + "directory", + "file", + "dangling-symlink", + "directory-symlink", + "malformed-manifest", + ] { + let project = temp.path().join(shape); + fs::create_dir(&project).unwrap(); + let root = project.join(".tree-ring"); + match shape { + "directory" => fs::create_dir(&root).unwrap(), + "file" => fs::write(&root, "not a directory").unwrap(), + "dangling-symlink" => symlink(project.join("absent"), &root).unwrap(), + "directory-symlink" => { + let external = temp.path().join("external-store"); + fs::create_dir(&external).unwrap(); + symlink(&external, &root).unwrap(); + } + "malformed-manifest" => { + fs::create_dir(&root).unwrap(); + fs::write(root.join("activation.json"), "broken-json").unwrap(); + } + _ => unreachable!(), + } + for harness in ["codex", "claude-code"] { + for event in HOOK_EVENTS { + let mut cli = Command::new(env!("CARGO_BIN_EXE_tree-ring")); + cli.current_dir(&project).arg("--root").arg(&root).args([ + "integrations", + "hook", + "--harness", + harness, + "--input-json-stdin", + ]); + let output = hook_output(cli, &lifecycle_input(event, &project)); + assert!(!output.status.success(), "{shape}/{harness}/{event}"); + assert!(output.stdout.is_empty()); + assert!(!output.stderr.is_empty()); + } + } + assert!(!root.join("memory.sqlite").exists()); + } +} + +#[test] +fn absent_root_skip_still_validates_harness_input_and_project_paths() { + let temp = tempdir().unwrap(); + let project = temp.path().join("project"); + fs::create_dir(&project).unwrap(); + let link = temp.path().join("linked-project"); + symlink(&project, &link).unwrap(); + let valid = lifecycle_input("SessionStart", &project); + let mut forbidden = valid.clone(); + forbidden["memory_root"] = json!("elsewhere"); + let cases = [ + (project.join(".tree-ring"), "unknown", valid.clone()), + (project.join(".tree-ring"), "codex", json!({})), + (project.join(".tree-ring"), "codex", forbidden), + (project.join("custom-store"), "codex", valid.clone()), + (link.join(".tree-ring"), "codex", valid.clone()), + ( + project.join(".tree-ring"), + "codex", + lifecycle_input("SessionStart", temp.path()), + ), + ]; + for (root, harness, input) in cases { + let mut cli = Command::new(env!("CARGO_BIN_EXE_tree-ring")); + cli.current_dir(&project).arg("--root").arg(root).args([ + "integrations", + "hook", + "--harness", + harness, + "--input-json-stdin", + ]); + let output = hook_output(cli, &input); + assert!(!output.status.success()); + assert!(!output.stderr.is_empty()); + } + // Explicit preflight remains strict; the no-op is limited to lifecycle hooks. + let output = Command::new(env!("CARGO_BIN_EXE_tree-ring")) + .current_dir(&project) + .args([ + "integrations", + "preflight", + "--harness", + "codex", + "--agent-profile", + "codex", + "--workflow-id", + "fixture", + "--session-id", + "fixture", + ]) + .output() + .unwrap(); + assert!(!output.status.success()); + assert!(!project.join(".tree-ring").exists()); +} + #[test] fn generated_hooks_capture_and_recall_across_sessions_with_only_a_local_runtime() { let temp = tempdir().unwrap(); diff --git a/docs/protocol/harness-activation.md b/docs/protocol/harness-activation.md index 90db86c..c9c32f4 100644 --- a/docs/protocol/harness-activation.md +++ b/docs/protocol/harness-activation.md @@ -349,6 +349,14 @@ other benign metadata. Tree Ring ignores those fields completely: it does not read, forward, log, persist, or include them in recall. Capability-bearing or root-selecting fields are rejected instead of ignored. +Lifecycle launchers use the current checkout's project-local `.tree-ring`, +including in a linked Git worktree. If that directory is genuinely absent, the +hook exits successfully without output or creating memory state. An inherited +hook does not initialize a worktree or borrow the main checkout's memory store. +An existing directory with missing or invalid activation, a file in place of +the directory, or a symlink remains an error requiring repair. Explicit capture +and preflight commands retain their strict activation requirements. + ### Codex and Claude Code lifecycle-hook output The managed command reads its event input from stdin and writes exactly one JSON diff --git a/plugins/AGENTS.md b/plugins/AGENTS.md index 57f41c7..9137dcf 100644 --- a/plugins/AGENTS.md +++ b/plugins/AGENTS.md @@ -10,7 +10,7 @@ tree-ring-memory contains Codex/Claude manifests, commands, skills, hooks, legal ## Local Contracts -Ship all four native lifecycle hooks. Codex skills-only means no MCP dependency; retain hooks and executable ZIP permissions. Project-owned hooks cause plugin hooks to stand down. +Ship all four native lifecycle hooks. Codex skills-only means no MCP dependency; retain hooks and executable ZIP permissions. Effective host-owned project hooks cause plugin hooks to stand down; Codex linked-worktree root layers use the proven primary hook source, while Claude keeps local ownership checks. A genuinely absent project-local .tree-ring is a quiet skip; an existing entry, including a dangling symlink, must retain runtime diagnostics. Never redirect worktree memory to the primary checkout's store. ## Work Guidance diff --git a/plugins/tree-ring-memory/.claude-plugin/plugin.json b/plugins/tree-ring-memory/.claude-plugin/plugin.json index 2eb3bd7..16117bd 100644 --- a/plugins/tree-ring-memory/.claude-plugin/plugin.json +++ b/plugins/tree-ring-memory/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "tree-ring-memory", "displayName": "Tree Ring Memory", - "version": "0.3.6", + "version": "0.3.7", "description": "Local-first memory lifecycle, project bootstrap, and receipt-backed harness guidance for Claude Code using Tree Ring Memory v0.15+.", "author": { "name": "TerminallyLazy", diff --git a/plugins/tree-ring-memory/.codex-plugin/plugin.json b/plugins/tree-ring-memory/.codex-plugin/plugin.json index 08c5017..c9a4c59 100644 --- a/plugins/tree-ring-memory/.codex-plugin/plugin.json +++ b/plugins/tree-ring-memory/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tree-ring-memory", - "version": "0.3.8", + "version": "0.3.9", "description": "Local-first memory lifecycle, project bootstrap, and receipt-backed harness guidance for coding agents using Tree Ring Memory v0.15+.", "author": { "name": "TerminallyLazy", diff --git a/plugins/tree-ring-memory/README.md b/plugins/tree-ring-memory/README.md index 8cba3ec..05c9a81 100644 --- a/plugins/tree-ring-memory/README.md +++ b/plugins/tree-ring-memory/README.md @@ -5,8 +5,8 @@ ChatGPT/Codex and Claude Code. It packages reviewed instructions plus thin lifecycle-hook registrations; the local Tree Ring Memory CLI remains the runtime and data owner. -The Codex manifest is version `0.3.8`. The Claude Code manifest is version -`0.3.6`. Both share the same reviewed wrapper skill. Manual guidance supports +The Codex manifest is version `0.3.9`. The Claude Code manifest is version +`0.3.7`. Both share the same reviewed wrapper skill. Manual guidance supports CLI `0.15.0` or newer; the lifecycle hooks require CLI `0.15.6` or newer for project-local runtime resolution and cross-session recall. DOX persistence requires CLI `0.15.11` or newer for source-root collision checks; older @@ -44,15 +44,27 @@ summary of every turn. The hook wrapper resolves the Git project root when available, prefers that project's `.tree-ring/bin/tree-ring`, and otherwise uses `tree-ring` from `PATH`. It then invokes the shared lifecycle entry point with the project-local -`.tree-ring` root. An unavailable or incompatible CLI is not active-harness -proof and cannot be reported as a successful checkpoint or capture. - -When project activation has already installed the managed lifecycle definition -in `.codex/hooks.json` or `.claude/settings.json`, that project definition owns -recall and stop checkpoints. The marketplace wrapper detects the exact managed -marker and exits without invoking the CLI, preventing duplicate context, -receipts, checkpoint continuations, or capture attempts when the host merges -project and plugin hooks. +`.tree-ring` root. A project or linked worktree with no local `.tree-ring` entry +is skipped quietly without creating memory or using the primary checkout's +store. Existing roots, including dangling symlinks and roots with missing or +invalid activation metadata, still reach runtime diagnostics. An unavailable +or incompatible CLI is not active-harness proof and cannot be reported as a +successful checkpoint or capture. + +When the host's effective project hook contains the managed lifecycle definition, +that definition owns recall and stop checkpoints. The marketplace wrapper +detects the exact managed marker and exits without invoking the CLI, preventing +duplicate context, receipts, checkpoint continuations, or capture attempts. + +For Codex's root `.codex` layer in a validated linked worktree, the wrapper +checks the corresponding primary-checkout `.codex/hooks.json` exclusively; +Codex ignores the worktree-local hook file in that case. The worktree must have +its own `.codex` directory for that layer to exist. Git metadata must prove the +reciprocal worktree and primary ownership; uncertain layouts dispatch normally. +This deduplication covers the root layer generated by Tree Ring, not custom +nested hook layers. Claude continues to check the local `.claude/settings.json`; +the wrapper does not assume that every Claude worktree inherits primary hooks. +Hook source selection never changes the current worktree's memory root. `integrations status --verbose` reports the last validated recall's result count and query class. A zero-result receipt proves the check ran; it does not diff --git a/plugins/tree-ring-memory/hooks/claude-hook.sh b/plugins/tree-ring-memory/hooks/claude-hook.sh index ee39783..adfc6e1 100755 --- a/plugins/tree-ring-memory/hooks/claude-hook.sh +++ b/plugins/tree-ring-memory/hooks/claude-hook.sh @@ -8,6 +8,12 @@ if command -v git >/dev/null 2>&1; then fi fi +# A linked worktree or unrelated project may not have opted into local memory. +# Existing roots, including dangling symlinks, still reach runtime diagnostics. +if [ ! -e .tree-ring ] && [ ! -L .tree-ring ]; then + exit 0 +fi + # Project activation owns lifecycle recall and checkpoints when its managed hook # is present. The marketplace hook stands down to prevent duplicate handling. if [ -f .claude/settings.json ] && { diff --git a/plugins/tree-ring-memory/hooks/codex-hook.sh b/plugins/tree-ring-memory/hooks/codex-hook.sh index 0e60bff..1262076 100755 --- a/plugins/tree-ring-memory/hooks/codex-hook.sh +++ b/plugins/tree-ring-memory/hooks/codex-hook.sh @@ -8,12 +8,116 @@ if command -v git >/dev/null 2>&1; then fi fi -# Project activation owns lifecycle recall and checkpoints when its managed hook -# is present. The marketplace hook stands down to prevent duplicate handling. -if [ -f .codex/hooks.json ] && { - grep -Fq 'Tree Ring Memory managed lifecycle v2"' .codex/hooks.json || - grep -Fq 'Tree Ring Memory managed lifecycle v3"' .codex/hooks.json || - grep -Fq 'Tree Ring Memory managed lifecycle v4"' .codex/hooks.json +# A linked worktree or unrelated project may not have opted into local memory. +# Existing roots, including dangling symlinks, still reach runtime diagnostics. +if [ ! -e .tree-ring ] && [ ! -L .tree-ring ]; then + exit 0 +fi + +# Read only bounded, ordinary Git metadata. Uncertain layouts must not suppress +# plugin dispatch based on a hook definition the host might never load. +read_git_metadata() { + [ -f "$1" ] && [ ! -L "$1" ] || return 1 + metadata_size=$(wc -c < "$1") || return 1 + [ "$metadata_size" -le 65536 ] || return 1 + metadata_without_nul_size=$(LC_ALL=C tr -d '\000' < "$1" | wc -c) || return 1 + [ "$metadata_size" -eq "$metadata_without_nul_size" ] || return 1 + metadata_value=$(cat "$1") || return 1 + case "$metadata_value" in + *' +'*) return 1 ;; + esac + printf '%s\n' "$metadata_value" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' +} + +canonical_directory() ( + [ -d "$1" ] && [ ! -L "$1" ] || exit 1 + cd "$1" && pwd -P +) + +gitdir_target() { + pointer=$(read_git_metadata "$1") || return 1 + case "$pointer" in + gitdir:*) pointer=${pointer#gitdir:} ;; + *) return 1 ;; + esac + pointer=$(printf '%s\n' "$pointer" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//') + [ -n "$pointer" ] || return 1 + case "$pointer" in + /*) printf '%s\n' "$pointer" ;; + *) printf '%s/%s\n' "$2" "$pointer" ;; + esac +} + +# Codex keeps normal worktree config local, but substitutes the corresponding +# primary-checkout hook source. Prove the reciprocal linked-worktree layout; +# a common-dir parent alone does not establish an owning primary checkout. +# Return 2 for an ordinary/non-linked checkout, 1 for uncertain metadata. +primary_hook_checkout() ( + checkout=$(pwd -P) || exit 1 + if [ -d .git ] && [ ! -L .git ]; then + exit 2 + fi + if [ ! -e .git ] && [ ! -L .git ]; then + exit 2 + fi + admin_path=$(gitdir_target "$checkout/.git" "$checkout") || exit 1 + admin=$(canonical_directory "$admin_path") || exit 1 + admin_parent=$(dirname "$admin") + [ "$(basename "$admin_parent")" = worktrees ] || exit 2 + common=$(dirname "$admin_parent") + backlink=$(read_git_metadata "$admin/gitdir") || exit 1 + [ -n "$backlink" ] || exit 1 + case "$backlink" in + /*) ;; + *) backlink="$admin/$backlink" ;; + esac + [ "$(basename "$backlink")" = .git ] || exit 1 + backlink_parent=$(canonical_directory "$(dirname "$backlink")") || exit 1 + [ "$backlink_parent" = "$checkout" ] || exit 1 + common_pointer=$(read_git_metadata "$admin/commondir") || exit 1 + [ -n "$common_pointer" ] || exit 1 + case "$common_pointer" in + /*) ;; + *) common_pointer="$admin/$common_pointer" ;; + esac + resolved_common=$(canonical_directory "$common_pointer") || exit 1 + [ "$resolved_common" = "$common" ] || exit 1 + # Match Codex's lexical candidate before proving ownership. Canonicalizing + # an alias of only the worktrees directory could otherwise invent a primary + # hook source that Codex itself rejects. + primary=$(dirname "$(dirname "$(dirname "$admin_path")")") + if [ -d "$primary/.git" ] && [ ! -L "$primary/.git" ]; then + primary_git=$(canonical_directory "$primary/.git") || exit 1 + else + primary_pointer=$(gitdir_target "$primary/.git" "$primary") || exit 1 + primary_git=$(canonical_directory "$primary_pointer") || exit 1 + fi + [ "$primary_git" = "$common" ] || exit 1 + cd "$primary" && pwd -P +) + +managed_hook=.codex/hooks.json +primary_checkout=$(primary_hook_checkout 2>/dev/null) && checkout_kind=0 || checkout_kind=$? +case "$checkout_kind" in + 0) + # No worktree .codex directory means Codex creates no root project layer. + if [ -d .codex ]; then + managed_hook="$primary_checkout/.codex/hooks.json" + else + managed_hook= + fi + ;; + 2) ;; + *) managed_hook= ;; +esac + +# Only the effective project hook owns recall and checkpoints. Never fall back +# to an ignored worktree-local hook when the primary source is absent. +if [ -n "$managed_hook" ] && [ -f "$managed_hook" ] && { + grep -Fq 'Tree Ring Memory managed lifecycle v2"' "$managed_hook" || + grep -Fq 'Tree Ring Memory managed lifecycle v3"' "$managed_hook" || + grep -Fq 'Tree Ring Memory managed lifecycle v4"' "$managed_hook" }; then exit 0 fi diff --git a/plugins/tree-ring-memory/packaging/codex-skills-only/.codex-plugin/plugin.json b/plugins/tree-ring-memory/packaging/codex-skills-only/.codex-plugin/plugin.json index 22aabfb..34e54d1 100644 --- a/plugins/tree-ring-memory/packaging/codex-skills-only/.codex-plugin/plugin.json +++ b/plugins/tree-ring-memory/packaging/codex-skills-only/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "tree-ring-memory", - "version": "0.3.8", + "version": "0.3.9", "description": "Local-first memory lifecycle, project bootstrap, and receipt-backed harness guidance for coding agents using Tree Ring Memory v0.15+.", "author": { "name": "TerminallyLazy", diff --git a/scripts/validate-plugin-packages.py b/scripts/validate-plugin-packages.py index 000bd7e..7a2f2f0 100644 --- a/scripts/validate-plugin-packages.py +++ b/scripts/validate-plugin-packages.py @@ -68,7 +68,7 @@ def validate_codex() -> None: manifest = load_json(PLUGIN / ".codex-plugin" / "plugin.json") require(manifest.get("name") == "tree-ring-memory", "Codex manifest name is stale") - require(manifest.get("version") == "0.3.8", "Codex manifest version is stale") + require(manifest.get("version") == "0.3.9", "Codex manifest version is stale") require(manifest.get("skills") == "./skills/", "Codex skills path is stale") require(manifest.get("hooks") == "./hooks/codex-hooks.json", "Codex lifecycle hook path is stale") for unsupported in ("mcpServers", "apps"): @@ -92,7 +92,7 @@ def validate_codex() -> None: def validate_claude() -> None: marketplace = load_json(ROOT / ".claude-plugin" / "marketplace.json") require(marketplace.get("name") == "tree-ring-memory", "Claude marketplace name is stale") - require(marketplace.get("version") == "0.3.6", "Claude marketplace version is stale") + require(marketplace.get("version") == "0.3.7", "Claude marketplace version is stale") require(isinstance(marketplace.get("owner"), dict), "Claude marketplace owner is required") entries = marketplace.get("plugins") require(isinstance(entries, list) and len(entries) == 1, "Claude marketplace must contain one plugin") @@ -159,6 +159,10 @@ def validate_hook_script(path: Path, harness: str) -> None: require(os.access(path, os.X_OK), f"{path.relative_to(ROOT)} must be executable") require(".tree-ring/bin/tree-ring" in text, f"{path.relative_to(ROOT)} must prefer the project-local CLI") require("git rev-parse --show-toplevel" in text, f"{path.relative_to(ROOT)} must resolve the project root") + require( + "[ ! -e .tree-ring ] && [ ! -L .tree-ring ]" in text, + f"{path.relative_to(ROOT)} must skip only genuinely absent memory roots", + ) managed_hook = ".codex/hooks.json" if harness == "codex" else ".claude/settings.json" require(managed_hook in text, f"{path.relative_to(ROOT)} must detect the project-managed hook") for version in (2, 3, 4): @@ -259,6 +263,197 @@ def validate_hook_script(path: Path, harness: str) -> None: require(stdin_capture.read_bytes() == events["SessionStart"], f"{path.relative_to(ROOT)} dropped v5 fallback input") require(b"validated" in unsupported.stdout, f"{path.relative_to(ROOT)} did not run the v5 fallback") + validate_hook_store_boundary(path) + + +def validate_hook_store_boundary(path: Path) -> None: + """Uninitialized worktrees are quiet; existing roots retain CLI failures.""" + with tempfile.TemporaryDirectory(prefix="tree-ring-hook-boundary-") as temporary: + base = Path(temporary) + primary = base / "primary checkout" + worktree = base / "linked worktree" + primary.mkdir() + subprocess.run(["git", "init", "-q", str(primary)], check=True, capture_output=True) + (primary / "fixture.txt").write_text("synthetic hook fixture\n", encoding="utf-8") + subprocess.run(["git", "-C", str(primary), "add", "fixture.txt"], check=True) + subprocess.run( + ["git", "-C", str(primary), "-c", "user.name=Tree Ring Test", + "-c", "user.email=test@example.invalid", "commit", "-qm", "fixture"], + check=True, + ) + subprocess.run( + ["git", "-C", str(primary), "worktree", "add", "--detach", str(worktree), "HEAD"], + check=True, capture_output=True, + ) + nested = worktree / "nested directory" + nested.mkdir() + unrelated = base / "uninitialized directory" + unrelated.mkdir() + sentinel = primary / ".tree-ring" / "sentinel" + sentinel.parent.mkdir() + sentinel.write_text("primary memory must remain untouched\n", encoding="utf-8") + fake_bin = base / "bin" + fake_bin.mkdir() + invoked = base / "invoked" + cli = fake_bin / "tree-ring" + cli.write_text( + "#!/bin/sh\n" + 'printf invoked > "$TREE_RING_TEST_INVOKED"\n' + "printf 'existing-store-diagnostic\\n' >&2\n" + "exit 42\n", + encoding="utf-8", + ) + cli.chmod(0o755) + environment = os.environ.copy() + environment["PATH"] = str(fake_bin) + os.pathsep + environment.get("PATH", "") + environment["TREE_RING_TEST_INVOKED"] = str(invoked) + + def run_events(cwd: Path, *, absent: bool) -> None: + for event in sorted(LIFECYCLE_EVENTS): + result = subprocess.run( + [str(path)], cwd=cwd, env=environment, + input=json.dumps({"hook_event_name": event, "session_id": "fixture", + "agent_id": "worker", "agent_type": "worker", + "cwd": str(cwd), "stop_hook_active": False}), + text=True, capture_output=True, + ) + if absent: + require(result.returncode == 0 and result.stdout == "" and result.stderr == "", + f"{path.relative_to(ROOT)} must quietly skip {event} without a local store") + require(not invoked.exists(), "absent-root hook must not invoke the CLI") + else: + require(result.returncode == 42 and "existing-store-diagnostic" in result.stderr, + f"{path.relative_to(ROOT)} hid the existing-root {event} diagnostic") + require(invoked.exists(), "existing-root hook must reach the CLI") + invoked.unlink() + + run_events(nested, absent=True) + run_events(unrelated, absent=True) + memory_root = worktree / ".tree-ring" + require(not memory_root.exists(), "hook must not initialize an absent worktree store") + memory_root.mkdir() + run_events(nested, absent=False) + (memory_root / "activation.json").write_text("{invalid", encoding="utf-8") + run_events(nested, absent=False) + (memory_root / "activation.json").unlink() + memory_root.rmdir() + memory_root.write_text("not a directory", encoding="utf-8") + run_events(nested, absent=False) + memory_root.unlink() + memory_root.symlink_to(worktree / "missing-target", target_is_directory=True) + run_events(nested, absent=False) + memory_root.unlink() + target = worktree / "existing-target" + target.mkdir() + memory_root.symlink_to(target, target_is_directory=True) + run_events(nested, absent=False) + require(sentinel.read_text(encoding="utf-8") == "primary memory must remain untouched\n", + "worktree hook must not alter primary-checkout memory") + if path.name == "codex-hook.sh": + validate_codex_worktree_hook_source(path, primary, worktree, nested, environment, invoked) + + +def validate_codex_worktree_hook_source( + path: Path, primary: Path, worktree: Path, cwd: Path, + environment: dict[str, str], invoked: Path, +) -> None: + """Match Codex's root-layer source replacement, not an OR of both files.""" + marker = '{"description":"Tree Ring Memory managed lifecycle v4"}\n' + local_dir = worktree / ".codex" + local_dir.mkdir() + local_hook = local_dir / "hooks.json" + local_hook.write_text(marker, encoding="utf-8") + primary_hook = primary / ".codex" / "hooks.json" + + def check(label: str, *, skip: bool) -> None: + for event in sorted(LIFECYCLE_EVENTS): + result = subprocess.run( + [str(path)], cwd=cwd, env=environment, + input=json.dumps({"hook_event_name": event, "session_id": "fixture", + "agent_id": "worker", "agent_type": "worker", + "cwd": str(cwd), "stop_hook_active": False}), + text=True, capture_output=True, + ) + if skip: + require(result.returncode == 0 and not result.stdout and not result.stderr, + f"Codex {label}: effective managed hook should own {event}") + require(not invoked.exists(), f"Codex {label}: duplicate CLI invocation") + else: + require(result.returncode == 42 and "existing-store-diagnostic" in result.stderr, + f"Codex {label}: ignored/uncertain hook must not suppress {event}") + require(invoked.exists(), f"Codex {label}: CLI was not invoked") + invoked.unlink() + + check("local-only managed hook is ignored", skip=False) + primary_hook.parent.mkdir() + primary_hook.write_text(marker, encoding="utf-8") + local_hook.unlink() + check("primary managed hook replaces local source", skip=True) + local_dir.rmdir() + check("absent worktree config directory creates no root layer", skip=False) + local_dir.mkdir() + local_hook.write_text(marker, encoding="utf-8") + primary_hook.write_text('{"description":"unmanaged hook"}\n', encoding="utf-8") + check("unmanaged primary does not fall back to managed local", skip=False) + primary_hook.write_text(marker, encoding="utf-8") + + admin = Path(subprocess.check_output( + ["git", "-C", str(worktree), "rev-parse", "--absolute-git-dir"], text=True, + ).strip()) + backlink = admin / "gitdir" + original_backlink = backlink.read_bytes() + backlink.write_text(str(primary / ".git") + "\n", encoding="utf-8") + check("mismatched reciprocal backlink", skip=False) + backlink.write_bytes(original_backlink) + saved_backlink = admin / "gitdir.fixture" + backlink.rename(saved_backlink) + backlink.symlink_to(saved_backlink) + check("symlinked reciprocal metadata", skip=False) + backlink.unlink() + saved_backlink.rename(backlink) + backlink.write_text("x" * 65537, encoding="utf-8") + check("oversized reciprocal metadata", skip=False) + backlink.write_bytes(original_backlink.rstrip(b"\n") + b"\x00\n") + check("NUL metadata must not normalize into a valid pointer", skip=False) + backlink.write_text(os.path.relpath(worktree / ".git", admin) + "\n", encoding="utf-8") + (worktree / ".git").write_text( + "gitdir: " + os.path.relpath(admin, worktree) + "\n", encoding="utf-8", + ) + check("relative reciprocal metadata", skip=True) + + outside = primary.parent / "outside" + outside.mkdir() + alias = outside / "alias" + alias.symlink_to(admin.parent, target_is_directory=True) + local_hook.write_text('{"description":"local unmanaged hook"}\n', encoding="utf-8") + (worktree / ".git").write_text( + "gitdir: " + str(alias / admin.name) + "\n", encoding="utf-8", + ) + check("partial directory alias cannot invent a primary source", skip=False) + repository_alias = primary.parent / "primary-alias" + repository_alias.symlink_to(primary, target_is_directory=True) + (worktree / ".git").write_text( + "gitdir: " + str(repository_alias / ".git" / "worktrees" / admin.name) + "\n", + encoding="utf-8", + ) + check("whole primary checkout alias proves ownership", skip=True) + + # A separate Git directory inside the primary checkout is valid only when + # the primary .git pointer proves ownership of that exact common directory. + old_common = primary / ".git" + new_common = primary / ".git-data" + relative_admin = admin.relative_to(old_common) + old_common.rename(new_common) + old_common.write_text("gitdir: .git-data\n", encoding="utf-8") + (worktree / ".git").write_text( + "gitdir: " + str(new_common / relative_admin) + "\n", encoding="utf-8", + ) + check("owned separate Git directory", skip=True) + other_common = primary.parent / "unowned-common" + other_common.mkdir() + old_common.write_text("gitdir: " + str(other_common) + "\n", encoding="utf-8") + check("unproven primary ownership", skip=False) + def validate_codex_skills_only() -> None: repository_manifest = load_json(PLUGIN / ".codex-plugin" / "plugin.json")