diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c04149e..5806062 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -4,14 +4,14 @@ "name": "TerminallyLazy", "url": "https://github.com/TerminallyLazy" }, - "description": "Claude Code marketplace for Tree Ring Memory v0.15 verified bootstrap, local-first recall, and receipt-backed harness readiness.", - "version": "0.3.2", + "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.3", "plugins": [ { "name": "tree-ring-memory", "source": "./plugins/tree-ring-memory", "displayName": "Tree Ring Memory", - "description": "Local-first memory lifecycle, project bootstrap, and receipt-backed harness guidance for Claude Code using Tree Ring Memory v0.15+.", + "description": "Local-first recall, strict agent-mediated automatic capture, project bootstrap, and receipt-backed harness guidance for Claude Code using Tree Ring Memory v0.15+.", "author": { "name": "TerminallyLazy", "url": "https://github.com/TerminallyLazy" diff --git a/Cargo.lock b/Cargo.lock index eab3cc5..e4b6abc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1690,7 +1690,7 @@ checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "tree-ring-memory-cli" -version = "0.15.4" +version = "0.15.5" dependencies = [ "chrono", "clap", @@ -1709,7 +1709,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-core" -version = "0.15.4" +version = "0.15.5" dependencies = [ "chrono", "libc", @@ -1725,7 +1725,7 @@ dependencies = [ [[package]] name = "tree-ring-memory-sqlite" -version = "0.15.4" +version = "0.15.5" dependencies = [ "rusqlite", "serde", diff --git a/Cargo.toml b/Cargo.toml index a7a45ec..0d4ea94 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.15.4" +version = "0.15.5" edition = "2021" license = "MIT" authors = ["TerminallyLazy"] diff --git a/README.md b/README.md index 6632517..5dd112b 100644 --- a/README.md +++ b/README.md @@ -244,8 +244,9 @@ files in the memory root: - `.tree-ring/AGENTS.md`: DOX-style Tree Ring Memory guidance and root `AGENTS.md` merge notes. - `.tree-ring/SKILL.md`: portable skill instructions for agent runtimes. -- `.tree-ring/CLI.md`: quick command reference for recall, remember, evidence, - DOX/Revolve sync, import/export, audit, maintenance, and TUI usage. +- `.tree-ring/CLI.md`: quick command reference for recall, strict automatic + capture, remember, evidence, DOX/Revolve sync, import/export, audit, + maintenance, and TUI usage. Existing awareness files are left untouched. Tree Ring Memory does not modify a project's root `AGENTS.md`; merge the generated guidance manually when you want @@ -337,6 +338,7 @@ The `tree-ring` command is the Rust CLI. ```bash tree-ring init tree-ring remember "Use protocol-first design." --event-type decision --tag architecture +tree-ring capture "Use receipt-backed lifecycle checkpoints." --event-type decision --ring cambium --project example-service --agent-profile codex --workflow-id workflow-1 --session-id session-1 --operation-id auto-checkpoint-1 --source-ref agent-checkpoint:checkpoint tree-ring evidence "Snapshot invalidation fixed stale unread chat state." --outcome promoted --evidence-ref evals/chat-state/run-042 --score 0.91 tree-ring recall "protocol design" tree-ring forget mem_example --mode delete --reason "example cleanup" @@ -361,6 +363,9 @@ Command ownership is Rust-native: `.tree-ring/SKILL.md`, and `.tree-ring/CLI.md` without overwriting existing files, then attempts create-only project harness configuration. - `remember`, `recall`, and `forget` cover direct memory capture, retrieval, redaction, and deletion. +- `capture` is the strict automatic lifecycle-write path: agent-scoped only, + normal sensitivity only, bounded cambium/scar/seed classifications, and + required checkpoint identity, idempotency, and provenance. - `evidence` is the Revolve-inspired improvement-loop entry point for evaluated outcomes. - `dox sync` and `revolve sync` are read-only source adapters that summarize and point back to authoritative files. - `integrations scan` discovers nearby agent-framework markers and suggests setup paths without changing their config. @@ -784,7 +789,7 @@ The current activation protocol is documented in ## Agent Workflow Integration -- `skills/tree-ring-memory/SKILL.md` gives agents portable guidance for when to recall, remember, redact, forget, or avoid memory capture. +- `skills/tree-ring-memory/SKILL.md` gives agents portable guidance for when to recall, automatically capture, remember, redact, forget, or avoid memory capture. - `templates/dox/AGENTS.md` is a DOX-style project contract template for repos that want Tree Ring Memory rules alongside source code. - `docs/integrations/agent-skill.md` explains how to use both without making memory more authoritative than local project docs. - `tree-ring init` creates canonical guidance and safely configures maintained @@ -814,11 +819,13 @@ view to `configured-awaiting-proof` and then receipt-backed `active`. There is no manual descriptor workflow, and the core/plugin release pair must be compatible before this path is available. -Memory updates are agent-mediated. Bridge files tell the active agent when to -call `tree-ring recall`, `tree-ring remember`, `tree-ring evidence`, -`tree-ring forget`, `tree-ring consolidate --dry-run`, or `tree-ring maintain`. -Tree Ring does not scrape transcripts, run a hidden recorder, or turn TUI -event-stream pulses into durable memory without an explicit write command. +Memory updates are agent-mediated. Maintained lifecycle bridges perform recall +at session and subagent start, then enforce one automatic checkpoint at stop. +The active agent submits zero to three concise durable normal-sensitivity +candidates through strict `tree-ring capture`; manual `remember` and `evidence` +remain separate surfaces. Zero is correct when nothing reusable occurred. +Tree Ring does not scrape transcripts, run a hidden recorder, or turn TUI event +streams into durable memory without a validated write command. ## Brand Assets diff --git a/crates/tree-ring-memory-cli/Cargo.toml b/crates/tree-ring-memory-cli/Cargo.toml index ad2df87..e8724fc 100644 --- a/crates/tree-ring-memory-cli/Cargo.toml +++ b/crates/tree-ring-memory-cli/Cargo.toml @@ -26,8 +26,8 @@ semver.workspace = true sha2.workspace = true tempfile.workspace = true uuid.workspace = true -tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.15.4" } -tree-ring-memory-sqlite = { path = "../tree-ring-memory-sqlite", version = "0.15.4" } +tree-ring-memory-core = { path = "../tree-ring-memory-core", version = "0.15.5" } +tree-ring-memory-sqlite = { path = "../tree-ring-memory-sqlite", version = "0.15.5" } [dev-dependencies] rusqlite.workspace = true diff --git a/crates/tree-ring-memory-cli/src/actions/remember.rs b/crates/tree-ring-memory-cli/src/actions/remember.rs index 9781652..6b2087f 100644 --- a/crates/tree-ring-memory-cli/src/actions/remember.rs +++ b/crates/tree-ring-memory-cli/src/actions/remember.rs @@ -24,6 +24,20 @@ pub struct RememberReport { pub created: bool, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CaptureRequest { + pub summary: String, + pub event_type: String, + pub ring: String, + pub project: String, + pub agent_profile: String, + pub workflow_id: String, + pub session_id: String, + pub operation_id: String, + pub source_ref: String, + pub tags: Vec, +} + pub fn remember( store: &mut SQLiteMemoryStore, request: RememberRequest, @@ -71,6 +85,96 @@ pub fn remember( Ok(RememberReport { memory, created }) } +/// Stores one strict agent-mediated automatic capture candidate. +/// +/// Automatic capture deliberately cannot create shared, heartwood, evaluation, +/// or sensitive memory. The lifecycle hook supplies the trusted routing and +/// checkpoint provenance; the active agent supplies only the concise durable +/// candidate and its bounded classification. +pub fn capture( + store: &mut SQLiteMemoryStore, + mut request: CaptureRequest, +) -> ActionResult { + let allowed = matches!( + (request.event_type.as_str(), request.ring.as_str()), + ("preference" | "decision", "cambium") + | ("lesson" | "correction", "cambium" | "scar") + | ("warning", "scar") + | ("seed", "seed") + ); + if !allowed { + if !matches!(request.ring.as_str(), "cambium" | "scar" | "seed") { + return Err("automatic capture uses an unsupported ring".to_string()); + } + return Err("automatic capture uses an unsupported event type or ring pairing".to_string()); + } + let checkpoint_id = request + .source_ref + .strip_prefix("agent-checkpoint:") + .filter(|value| { + !value.is_empty() + && value.len() <= 128 + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_.".contains(character)) + }) + .ok_or_else(|| { + "automatic capture source-ref must contain a safe agent-checkpoint id".to_string() + })?; + let operation_prefix = format!("auto-{checkpoint_id}-"); + let slot = request + .operation_id + .strip_prefix(&operation_prefix) + .filter(|slot| matches!(*slot, "1" | "2" | "3")); + if slot.is_none() { + return Err( + "automatic capture operation-id must match its checkpoint and slot 1, 2, or 3" + .to_string(), + ); + } + + let guard = SensitivityGuard::default(); + let values = [ + request.summary.as_str(), + request.event_type.as_str(), + request.ring.as_str(), + request.project.as_str(), + request.agent_profile.as_str(), + request.workflow_id.as_str(), + request.session_id.as_str(), + request.operation_id.as_str(), + request.source_ref.as_str(), + ] + .into_iter() + .chain(request.tags.iter().map(String::as_str)); + let sensitivity = guard + .detect_text_sensitivity(values) + .map_err(|_| "automatic capture accepts only normal-sensitivity candidates".to_string())?; + if sensitivity != "normal" { + return Err("automatic capture accepts only normal-sensitivity candidates".to_string()); + } + if !request.tags.iter().any(|tag| tag == "automatic-capture") { + request.tags.push("automatic-capture".to_string()); + } + + remember( + store, + RememberRequest { + summary: request.summary, + event_type: request.event_type, + ring: request.ring, + scope: "agent".to_string(), + project: Some(request.project), + agent_profile: Some(request.agent_profile), + workflow_id: Some(request.workflow_id), + session_id: Some(request.session_id), + operation_id: Some(request.operation_id), + source_ref: Some(request.source_ref), + tags: request.tags, + }, + ) +} + pub fn store_event_idempotently( store: &mut SQLiteMemoryStore, event: &MemoryEvent, @@ -207,6 +311,85 @@ mod tests { assert_eq!(report.memory.source.ref_, "runs/fanout-42/reviewer-2.json"); } + #[test] + fn automatic_capture_stores_only_agent_scoped_checkpoint_memory() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + + let report = capture( + &mut store, + CaptureRequest { + summary: "Use receipt-backed lifecycle hooks for automatic recall.".to_string(), + event_type: "decision".to_string(), + ring: "cambium".to_string(), + project: "tree-ring-memory".to_string(), + agent_profile: "codex".to_string(), + workflow_id: "workflow-1".to_string(), + session_id: "session-1".to_string(), + operation_id: "auto-checkpoint-1-1".to_string(), + source_ref: "agent-checkpoint:checkpoint-1".to_string(), + tags: vec!["hooks".to_string()], + }, + ) + .unwrap(); + + assert!(report.created); + assert_eq!(report.memory.scope, "agent"); + assert_eq!(report.memory.agent_profile.as_deref(), Some("codex")); + assert!(report + .memory + .tags + .iter() + .any(|tag| tag == "automatic-capture")); + } + + #[test] + fn automatic_capture_rejects_sensitive_or_unbounded_candidates() { + let dir = tempdir().unwrap(); + let mut store = SQLiteMemoryStore::open(dir.path().join("memory.sqlite")).unwrap(); + let request = |summary: &str, event_type: &str, ring: &str| CaptureRequest { + summary: summary.to_string(), + event_type: event_type.to_string(), + ring: ring.to_string(), + project: "tree-ring-memory".to_string(), + agent_profile: "codex".to_string(), + workflow_id: "workflow-1".to_string(), + session_id: "session-1".to_string(), + operation_id: "auto-checkpoint-1-1".to_string(), + source_ref: "agent-checkpoint:checkpoint-1".to_string(), + tags: Vec::new(), + }; + + assert!(capture( + &mut store, + request( + "token = sk-proj-abcdefghijklmnopqrstuvwxyz1234567890", + "lesson", + "cambium" + ) + ) + .unwrap_err() + .contains("normal-sensitivity")); + assert!(capture( + &mut store, + request("Promote this automatically.", "decision", "heartwood") + ) + .unwrap_err() + .contains("unsupported ring")); + assert!(capture( + &mut store, + request("Store a transcript summary.", "transcript", "cambium") + ) + .unwrap_err() + .contains("unsupported event type")); + let mut mismatched = request("Store a fourth candidate.", "lesson", "cambium"); + mismatched.operation_id = "auto-checkpoint-1-4".to_string(); + assert!(capture(&mut store, mismatched) + .unwrap_err() + .contains("slot 1, 2, or 3")); + assert!(store.list_all(false).unwrap().is_empty()); + } + #[test] fn operation_id_replay_is_idempotent_and_conflicts_fail_closed() { let dir = tempdir().unwrap(); diff --git a/crates/tree-ring-memory-cli/src/activation/adapters.rs b/crates/tree-ring-memory-cli/src/activation/adapters.rs index fd28f7f..fbf5ec2 100644 --- a/crates/tree-ring-memory-cli/src/activation/adapters.rs +++ b/crates/tree-ring-memory-cli/src/activation/adapters.rs @@ -20,6 +20,7 @@ const AGENT_ZERO_CAPABILITY_CONTRACTS: &[(&str, &str, &str)] = &[ ("3.2.0", "0.15.3", "0.15"), ("3.3.0", "0.15.3", "0.15"), ("3.3.1", "0.15.3", "0.15"), + ("3.4.0", "0.15.5", "0.15"), ]; const MAX_AGENT_ZERO_CAPABILITY_BYTES: u64 = 16 * 1024; @@ -392,20 +393,20 @@ enum AdapterSupport { const ADAPTERS: [DeclarativeAdapter; 7] = [ DeclarativeAdapter { id: "codex", - version: "1", + version: "3", display_name: "Codex", command: "codex", - capability: AdapterCapability::GuidanceOnly, + capability: AdapterCapability::NativePreflight, markers: &[".codex", "AGENTS.md"], home_markers: &[".codex"], support: AdapterSupport::Maintained, }, DeclarativeAdapter { id: "claude-code", - version: "1", + version: "3", display_name: "Claude Code", command: "claude", - capability: AdapterCapability::WrapperPreflight, + capability: AdapterCapability::NativePreflight, markers: &[".claude", "CLAUDE.md"], home_markers: &[".claude"], support: AdapterSupport::Maintained, @@ -704,6 +705,7 @@ fn adapter_writes(adapter: &DeclarativeAdapter) -> Vec { match adapter.id { "codex" => vec![ bridge_write(".agents/skills/tree-ring-memory/SKILL.md"), + bridge_write(".codex/hooks.json"), managed_block("AGENTS.md", "codex"), ], "claude-code" => vec![ @@ -952,10 +954,10 @@ mod tests { .collect::>(), vec!["codex", "claude-code", "pi", "agent-zero"] ); - assert!(maintained.iter().all(|adapter| adapter.version() == "1")); - assert!(maintained - .iter() - .all(|adapter| adapter_version(adapter.id()) == Some("1"))); + assert_eq!(adapter_version("codex"), Some("3")); + assert_eq!(adapter_version("claude-code"), Some("3")); + assert_eq!(adapter_version("pi"), Some("1")); + assert_eq!(adapter_version("agent-zero"), Some("1")); for id in ["hermes", "opencode", "goose"] { assert_eq!( plan_activation(id, &project()).unwrap().state, @@ -965,18 +967,14 @@ mod tests { } #[test] - fn only_claude_code_advertises_a_tested_wrapper_preflight() { + fn maintained_hook_harnesses_advertise_native_preflight() { let report = detect_adapters(&project(), &FakeEnvironment::default()); - assert_eq!( - report.by_id("claude-code").unwrap().capability, - AdapterCapability::WrapperPreflight - ); - for harness_id in ["codex", "pi", "agent-zero"] { - assert_ne!( + for harness_id in ["codex", "claude-code", "pi", "agent-zero"] { + assert_eq!( report.by_id(harness_id).unwrap().capability, - AdapterCapability::WrapperPreflight, - "{harness_id} must not advertise a generic launch wrapper" + AdapterCapability::NativePreflight, + "{harness_id} must advertise its tested native lifecycle bridge" ); } } @@ -1117,6 +1115,21 @@ mod tests { Some(AgentZeroPluginManifest::compatible()) ); + fs::write( + plugin.join("plugin.yaml"), + "name: tree_ring_memory\nversion: 3.4.0\n", + ) + .unwrap(); + fs::write( + &descriptor, + r#"{"schema_version":1,"kind":"tree-ring-agent-zero-plugin-capability","plugin_id":"tree_ring_memory","plugin_version":"3.4.0","activation_protocol_version":1,"tree_ring_version":{"min":"0.15.5","minor":"0.15"},"enabled":true}"#, + ) + .unwrap(); + assert_eq!( + read_agent_zero_plugin_manifest(&project, &descriptor), + Some(AgentZeroPluginManifest::compatible()) + ); + fs::write( plugin.join("plugin.yaml"), "name: tree_ring_memory\nversion: 3.3.1\n", diff --git a/crates/tree-ring-memory-cli/src/activation/bridge.rs b/crates/tree-ring-memory-cli/src/activation/bridge.rs index 7715a28..7e34703 100644 --- a/crates/tree-ring-memory-cli/src/activation/bridge.rs +++ b/crates/tree-ring-memory-cli/src/activation/bridge.rs @@ -39,8 +39,14 @@ use std::{ use uuid::Uuid; const CODEX_RETRY: &str = "tree-ring integrations activate --harness codex --accept-managed-block"; -const CLAUDE_DESCRIPTION: &str = "Tree Ring Memory managed preflight v1"; -const CLAUDE_COMMAND: &str = "tree-ring --root .tree-ring integrations preflight --harness claude-code --input-json-stdin --context-format claude-session-start"; +const CLAUDE_DESCRIPTION: &str = "Tree Ring Memory managed lifecycle v3"; +const CLAUDE_SUBAGENT_DESCRIPTION: &str = "Tree Ring Memory managed subagent lifecycle v3"; +const CLAUDE_STOP_DESCRIPTION: &str = "Tree Ring Memory managed capture checkpoint v3"; +const CLAUDE_SUBAGENT_STOP_DESCRIPTION: &str = + "Tree Ring Memory managed subagent capture checkpoint v3"; +const CLAUDE_LEGACY_DESCRIPTION: &str = "Tree Ring Memory managed lifecycle v2"; +const CLAUDE_LEGACY_SUBAGENT_DESCRIPTION: &str = "Tree Ring Memory managed subagent lifecycle v2"; +const CLAUDE_COMMAND: &str = "project_root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; tree-ring --root \"$project_root/.tree-ring\" integrations hook --harness claude-code --input-json-stdin"; const SKILL_BRIDGE: &str = r#"--- name: tree-ring-memory @@ -54,9 +60,52 @@ Read `.tree-ring/SKILL.md`, `.tree-ring/AGENTS.md`, and `.tree-ring/CLI.md` from const CODEX_BLOCK_BODY: &str = r#"## Tree Ring Memory -Read `.tree-ring/AGENTS.md`, `.tree-ring/SKILL.md`, and `.tree-ring/CLI.md` before substantive work. Use `tree-ring --root .tree-ring integrations preflight --harness codex` with the later preflight identity interface, and do not claim memory is active without a valid project-local recall receipt. +Read `.tree-ring/AGENTS.md`, `.tree-ring/SKILL.md`, and `.tree-ring/CLI.md` before substantive work. The project lifecycle hook runs receipt-backed recall at session boundaries and one strict automatic capture checkpoint before a session or subagent turn stops. Do not claim memory is active without a valid project-local recall receipt. Capture zero to three concise durable candidates; never store a transcript or invent memory. "#; +fn codex_hooks() -> Value { + json!({ + "description": "Tree Ring Memory managed lifecycle v3", + "hooks": { + "SessionStart": [{ + "matcher": "startup|resume|clear|compact", + "hooks": [{ + "type": "command", + "command": "project_root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; tree-ring --root \"$project_root/.tree-ring\" integrations hook --harness codex --input-json-stdin", + "timeout": 10, + "statusMessage": "Loading Tree Ring memory", + "additionalContextLimit": 2500 + }] + }], + "SubagentStart": [{ + "hooks": [{ + "type": "command", + "command": "project_root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; tree-ring --root \"$project_root/.tree-ring\" integrations hook --harness codex --input-json-stdin", + "timeout": 10, + "statusMessage": "Loading Tree Ring memory", + "additionalContextLimit": 2500 + }] + }], + "Stop": [{ + "hooks": [{ + "type": "command", + "command": "project_root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; tree-ring --root \"$project_root/.tree-ring\" integrations hook --harness codex --input-json-stdin", + "timeout": 10, + "statusMessage": "Checking durable Tree Ring memory" + }] + }], + "SubagentStop": [{ + "hooks": [{ + "type": "command", + "command": "project_root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; tree-ring --root \"$project_root/.tree-ring\" integrations hook --harness codex --input-json-stdin", + "timeout": 10, + "statusMessage": "Checking durable Tree Ring worker memory" + }] + }] + } + }) +} + const PI_EXTENSION: &str = r#"import { spawn } from "node:child_process"; import type { ExtensionAPI } from "@mariozechner/pi-coding-agent"; @@ -1945,8 +1994,9 @@ fn prepare_claude_settings( ) -> Result, String> { let before = project_fs.read_optional(&write.path)?; let path = relative_string(&write.path)?; - let handler = claude_handler(); - let handler_hash = sha256(&serde_json::to_vec(&handler).map_err(json_error)?); + 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)?); if before.is_none() { if owned_files.iter().any(|owned| owned.path == path) || managed_blocks @@ -1956,7 +2006,7 @@ fn prepare_claude_settings( return Ok(None); } let mut root = Map::new(); - insert_claude_handler(&mut root, handler)?; + insert_claude_handlers(&mut root)?; let bytes = pretty_json(&Value::Object(root))?; upsert_owned_file(owned_files, path, sha256(&bytes)); return Ok(Some(PreparedFile { @@ -1978,11 +2028,15 @@ fn prepare_claude_settings( match state { ClaudeHandlerState::Exact => {} ClaudeHandlerState::Absent => { - if insert_claude_handler(&mut root, handler).is_err() { + if insert_claude_handlers(&mut root).is_err() { + return Ok(None); + } + } + ClaudeHandlerState::Conflict => { + if !replace_legacy_claude_handlers(&mut root)? { return Ok(None); } } - ClaudeHandlerState::Conflict => return Ok(None), } let desired = pretty_json(&Value::Object(root))?; upsert_owned_file(owned_files, path, sha256(&desired)); @@ -2002,11 +2056,20 @@ fn prepare_claude_settings( Ok(state) => state, Err(_) => return Ok(None), }; + let owns_legacy_bundle = managed_blocks.iter().any(|owned| { + owned.path == path + && owned.block_id == write.block_id + && owned.sha256 == legacy_handler_hash + }); match state { - ClaudeHandlerState::Conflict => return Ok(None), + ClaudeHandlerState::Conflict => { + if !owns_legacy_bundle || !replace_legacy_claude_handlers(&mut root)? { + return Ok(None); + } + } ClaudeHandlerState::Exact => {} ClaudeHandlerState::Absent => { - if insert_claude_handler(&mut root, handler).is_err() { + if insert_claude_handlers(&mut root).is_err() { return Ok(None); } } @@ -2036,6 +2099,7 @@ fn render_complete_file( | ("claude-code", Some(".claude/skills/tree-ring-memory/SKILL.md")) => { Ok(SKILL_BRIDGE.as_bytes().to_vec()) } + ("codex", Some(".codex/hooks.json")) => pretty_json(&codex_hooks()), ("pi", Some(".pi/extensions/tree-ring-memory.ts")) => Ok(PI_EXTENSION.as_bytes().to_vec()), ("agent-zero", Some(".tree-ring/activation/agent-zero.json")) => { let binding = json!({ @@ -2075,6 +2139,7 @@ fn validate_plan(plan: &AdapterPlan) -> Result<(), String> { match plan.harness_id.as_str() { "codex" => vec![ ("file", ".agents/skills/tree-ring-memory/SKILL.md", ""), + ("file", ".codex/hooks.json", ""), ("block", "AGENTS.md", "codex"), ], "claude-code" => vec![ @@ -2244,51 +2309,121 @@ fn claude_handler() -> Value { json!({ "type": "command", "command": CLAUDE_COMMAND, - "description": CLAUDE_DESCRIPTION + "description": CLAUDE_DESCRIPTION, + "timeout": 10 + }) +} + +fn claude_subagent_handler() -> Value { + json!({ + "type": "command", + "command": CLAUDE_COMMAND, + "description": CLAUDE_SUBAGENT_DESCRIPTION, + "timeout": 10 + }) +} + +fn claude_stop_handler() -> Value { + json!({ + "type": "command", + "command": CLAUDE_COMMAND, + "description": CLAUDE_STOP_DESCRIPTION, + "timeout": 10 }) } +fn claude_subagent_stop_handler() -> Value { + json!({ + "type": "command", + "command": CLAUDE_COMMAND, + "description": CLAUDE_SUBAGENT_STOP_DESCRIPTION, + "timeout": 10 + }) +} + +fn legacy_claude_handlers() -> Vec<(&'static str, Value)> { + vec![ + ( + "SessionStart", + json!({ + "type": "command", + "command": CLAUDE_COMMAND, + "description": CLAUDE_LEGACY_DESCRIPTION, + "timeout": 10 + }), + ), + ( + "SubagentStart", + json!({ + "type": "command", + "command": CLAUDE_COMMAND, + "description": CLAUDE_LEGACY_SUBAGENT_DESCRIPTION, + "timeout": 10 + }), + ), + ] +} + +fn claude_handlers() -> Vec<(&'static str, Value)> { + vec![ + ("SessionStart", claude_handler()), + ("SubagentStart", claude_subagent_handler()), + ("Stop", claude_stop_handler()), + ("SubagentStop", claude_subagent_stop_handler()), + ] +} + fn inspect_claude_handler(root: &Map) -> Result { validate_claude_hook_shape(root)?; - let expected = claude_handler(); + let expected = claude_handlers(); let mut exact = 0usize; + let mut claimed = 0usize; let mut conflict = false; - if let Some(entries) = root - .get("hooks") - .and_then(Value::as_object) - .and_then(|hooks| hooks.get("SessionStart")) - .and_then(Value::as_array) - { - for entry in entries { - let handlers = entry - .get("hooks") - .and_then(Value::as_array) - .expect("validated SessionStart handler array"); - for handler in handlers { - let object = handler - .as_object() - .expect("validated SessionStart handler object"); - let description = object.get("description").and_then(Value::as_str); - let command = object.get("command").and_then(Value::as_str); - let claims_description = description == Some(CLAUDE_DESCRIPTION); - let claims_command = command.is_some_and(|command| { - command.contains("tree-ring") - && command.contains("integrations preflight") - && command.contains("--harness claude-code") - }); - if claims_description || claims_command { - if handler == &expected { - exact += 1; - } else { - conflict = true; + for (event, expected_handler) in &expected { + if let Some(entries) = root + .get("hooks") + .and_then(Value::as_object) + .and_then(|hooks| hooks.get(*event)) + .and_then(Value::as_array) + { + for entry in entries { + let handlers = entry + .get("hooks") + .and_then(Value::as_array) + .expect("validated Claude lifecycle handler array"); + for handler in handlers { + let object = handler + .as_object() + .expect("validated Claude lifecycle handler object"); + let description = object.get("description").and_then(Value::as_str); + let command = object.get("command").and_then(Value::as_str); + let claims_description = matches!( + description, + Some( + CLAUDE_DESCRIPTION + | CLAUDE_SUBAGENT_DESCRIPTION + | CLAUDE_STOP_DESCRIPTION + | CLAUDE_SUBAGENT_STOP_DESCRIPTION + ) + ); + let claims_command = command.is_some_and(|command| { + command.contains("tree-ring") && command.contains("--harness claude-code") + }); + if claims_description || claims_command { + claimed += 1; + if handler == expected_handler { + exact += 1; + } else { + conflict = true; + } } } } } } - if conflict || exact > 1 { + if conflict || claimed > expected.len() || (claimed != 0 && exact != expected.len()) { Ok(ClaudeHandlerState::Conflict) - } else if exact == 1 { + } else if exact == expected.len() { Ok(ClaudeHandlerState::Exact) } else { Ok(ClaudeHandlerState::Absent) @@ -2302,27 +2437,30 @@ fn validate_claude_hook_shape(root: &Map) -> Result<(), String> { let hooks = hooks .as_object() .ok_or_else(|| "Claude settings hooks must be an object".to_string())?; - if let Some(session_start) = hooks.get("SessionStart") { - let entries = session_start + for event in ["SessionStart", "SubagentStart", "Stop", "SubagentStop"] { + let Some(event_value) = hooks.get(event) else { + continue; + }; + let entries = event_value .as_array() - .ok_or_else(|| "Claude SessionStart hooks must be an array".to_string())?; + .ok_or_else(|| format!("Claude {event} hooks must be an array"))?; for entry in entries { let entry = entry .as_object() - .ok_or_else(|| "Claude SessionStart entry must be an object".to_string())?; + .ok_or_else(|| format!("Claude {event} entry must be an object"))?; let handlers = entry .get("hooks") .and_then(Value::as_array) - .ok_or_else(|| "Claude SessionStart entry hooks must be an array".to_string())?; + .ok_or_else(|| format!("Claude {event} entry hooks must be an array"))?; if handlers.iter().any(|handler| !handler.is_object()) { - return Err("Claude SessionStart command handler must be an object".to_string()); + return Err(format!("Claude {event} command handler must be an object")); } } } Ok(()) } -fn insert_claude_handler(root: &mut Map, handler: Value) -> Result<(), String> { +fn insert_claude_handlers(root: &mut Map) -> Result<(), String> { if !root.contains_key("hooks") { root.insert("hooks".to_string(), Value::Object(Map::new())); } @@ -2330,17 +2468,58 @@ fn insert_claude_handler(root: &mut Map, handler: Value) -> Resul .get_mut("hooks") .and_then(Value::as_object_mut) .ok_or_else(|| "Claude settings hooks must be an object".to_string())?; - if !hooks.contains_key("SessionStart") { - hooks.insert("SessionStart".to_string(), Value::Array(Vec::new())); - } - let session_start = hooks - .get_mut("SessionStart") - .and_then(Value::as_array_mut) - .ok_or_else(|| "Claude SessionStart hooks must be an array".to_string())?; - session_start.push(json!({"matcher": "", "hooks": [handler]})); + for (event, handler) in claude_handlers() { + if !hooks.contains_key(event) { + hooks.insert(event.to_string(), Value::Array(Vec::new())); + } + let entries = hooks + .get_mut(event) + .and_then(Value::as_array_mut) + .ok_or_else(|| format!("Claude {event} hooks must be an array"))?; + entries.push(json!({"matcher": "", "hooks": [handler]})); + } Ok(()) } +fn replace_legacy_claude_handlers(root: &mut Map) -> Result { + let legacy = legacy_claude_handlers(); + 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 { + let Some(entries) = hooks.get_mut(*event).and_then(Value::as_array_mut) else { + return Ok(false); + }; + for entry in entries.iter_mut() { + let Some(handlers) = entry.get_mut("hooks").and_then(Value::as_array_mut) else { + continue; + }; + handlers.retain(|handler| { + let matches = handler == expected_handler; + if matches { + removed += 1; + } + !matches + }); + } + entries.retain(|entry| { + entry + .get("hooks") + .and_then(Value::as_array) + .is_none_or(|handlers| !handlers.is_empty()) + }); + if entries.is_empty() { + hooks.remove(*event); + } + } + if removed != legacy.len() { + return Ok(false); + } + insert_claude_handlers(root)?; + Ok(true) +} + fn remove_claude_handler(bytes: &[u8], expected_hash: &str) -> Result>, String> { let mut root = match parse_json_object(bytes) { Ok(root) => root, @@ -2349,41 +2528,43 @@ fn remove_claude_handler(bytes: &[u8], expected_hash: &str) -> Result Result<(), LaunchError>, { - if adapter_capability(&request.harness_id) != Some(AdapterCapability::WrapperPreflight) { + if request.harness_id != "claude-code" + || !matches!( + adapter_capability(&request.harness_id), + Some(AdapterCapability::WrapperPreflight | AdapterCapability::NativePreflight) + ) + { return Err(LaunchError::new(format!( "harness {} does not provide a wrapper preflight", request.harness_id ))); } - if request.harness_id != "claude-code" { - return Err(LaunchError::new( - "only the Claude Code wrapper is supported", - )); - } - let prepared = prepare_preflight( store, project, @@ -299,8 +298,8 @@ mod tests { fs::create_dir_all(&project.memory_root).unwrap(); let mut activation = HarnessActivation { state: ActivationState::ConfiguredAwaitingProof, - adapter_capability: AdapterCapability::WrapperPreflight, - adapter_version: "1".to_string(), + adapter_capability: AdapterCapability::NativePreflight, + adapter_version: "3".to_string(), bridge_fingerprint: String::new(), bridge_path: Some(".claude/skills/tree-ring-memory/SKILL.md".to_string()), owned_files: vec![OwnedBridgeFile { @@ -623,7 +622,7 @@ mod tests { .harnesses .get_mut("claude-code") .unwrap() - .adapter_capability = AdapterCapability::NativePreflight; + .adapter_capability = AdapterCapability::WrapperPreflight; spawner.on_spawn = Some(Box::new(move || { save_manifest(&memory_root, &changed).unwrap(); })); @@ -656,7 +655,7 @@ mod tests { .harnesses .get_mut("claude-code") .unwrap() - .adapter_capability = AdapterCapability::NativePreflight; + .adapter_capability = AdapterCapability::WrapperPreflight; spawner.on_spawn = Some(Box::new(move || { save_manifest(&memory_root, &changed).unwrap(); })); diff --git a/crates/tree-ring-memory-cli/src/activation/lifecycle.rs b/crates/tree-ring-memory-cli/src/activation/lifecycle.rs new file mode 100644 index 0000000..f6f44d2 --- /dev/null +++ b/crates/tree-ring-memory-cli/src/activation/lifecycle.rs @@ -0,0 +1,348 @@ +use super::{ + adapters::ActivationProject, + manifest::fingerprint, + preflight::{PreflightRequest, PreflightResponse}, + SessionIdentity, +}; +use serde_json::{json, Map, Value}; +use std::path::Path; +use tree_ring_memory_core::SensitivityGuard; +use uuid::Uuid; + +const MAX_HOOK_INPUT_BYTES: usize = 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum LifecycleHookEvent { + SessionStart, + SubagentStart, + Stop, + SubagentStop, +} + +impl LifecycleHookEvent { + fn parse(value: &str) -> Result { + match value { + "SessionStart" => Ok(Self::SessionStart), + "SubagentStart" => Ok(Self::SubagentStart), + "Stop" => Ok(Self::Stop), + "SubagentStop" => Ok(Self::SubagentStop), + _ => Err("unsupported lifecycle hook event".to_string()), + } + } + + fn as_str(self) -> &'static str { + match self { + Self::SessionStart => "SessionStart", + Self::SubagentStart => "SubagentStart", + Self::Stop => "Stop", + Self::SubagentStop => "SubagentStop", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct CaptureCheckpoint { + pub identity: SessionIdentity, + pub project: String, + pub checkpoint_id: String, + pub stop_hook_active: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct LifecycleHookRequest { + pub event: LifecycleHookEvent, + pub preflight: Option, + pub capture_checkpoint: Option, +} + +/// Normalizes only stable, harness-owned lifecycle fields. Transcript paths, +/// model output, prompts, and arbitrary extension fields are deliberately +/// ignored and therefore cannot enter recall queries or activation receipts. +pub fn parse_lifecycle_hook( + project: &ActivationProject, + harness_id: &str, + input: &str, +) -> Result { + if input.len() > MAX_HOOK_INPUT_BYTES { + return Err("lifecycle hook stdin exceeds 1 MiB".to_string()); + } + if !matches!(harness_id, "codex" | "claude-code") { + return Err("unknown lifecycle hook harness".to_string()); + } + let value: Value = + serde_json::from_str(input).map_err(|_| "invalid lifecycle hook stdin".to_string())?; + let object = value + .as_object() + .ok_or_else(|| "invalid lifecycle hook stdin".to_string())?; + reject_capability_fields(object)?; + + let event = LifecycleHookEvent::parse(required_string(object, "hook_event_name")?)?; + let cwd = Path::new(required_string(object, "cwd")?); + ensure_cwd_inside_project(project, cwd)?; + let parent_session = normalized("session", required_string(object, "session_id")?); + + let identity = match event { + LifecycleHookEvent::SessionStart | LifecycleHookEvent::Stop => SessionIdentity { + agent_profile: harness_id.to_string(), + workflow_id: parent_session.clone(), + session_id: parent_session, + }, + LifecycleHookEvent::SubagentStart | LifecycleHookEvent::SubagentStop => { + let agent_id = required_string(object, "agent_id")?; + let agent_type = normalized("agent-type", required_string(object, "agent_type")?); + SessionIdentity { + agent_profile: format!( + "{harness_id}:{agent_type}:{}", + &fingerprint(agent_id)[..16] + ), + workflow_id: parent_session, + session_id: normalized("agent", agent_id), + } + } + }; + + let (preflight, capture_checkpoint) = match event { + LifecycleHookEvent::SessionStart | LifecycleHookEvent::SubagentStart => ( + Some(PreflightRequest::lifecycle_hook( + harness_id, + identity.clone(), + )), + None, + ), + LifecycleHookEvent::Stop | LifecycleHookEvent::SubagentStop => { + let stop_hook_active = object + .get("stop_hook_active") + .and_then(Value::as_bool) + .ok_or_else(|| "invalid lifecycle hook stdin".to_string())?; + let project_name = project + .project_root + .file_name() + .and_then(|name| name.to_str()) + .filter(|name| !name.trim().is_empty()) + .unwrap_or("project"); + ( + None, + Some(CaptureCheckpoint { + identity, + project: normalized("project", project_name), + checkpoint_id: Uuid::new_v4().hyphenated().to_string(), + stop_hook_active, + }), + ) + } + }; + + Ok(LifecycleHookRequest { + event, + preflight, + capture_checkpoint, + }) +} + +pub fn render_lifecycle_hook( + event: LifecycleHookEvent, + response: &PreflightResponse, +) -> Result { + serde_json::to_string(&json!({ + "hookSpecificOutput": { + "hookEventName": event.as_str(), + "additionalContext": response.context, + } + })) + .map_err(|_| "failed to serialize lifecycle hook context".to_string()) +} + +pub fn render_capture_checkpoint(checkpoint: &CaptureCheckpoint) -> Result { + if checkpoint.stop_hook_active { + return Ok("{}".to_string()); + } + let identity = &checkpoint.identity; + let reason = format!( + "Tree Ring automatic capture checkpoint {checkpoint_id}. Before stopping, review only the durable outcomes already available in your working context. For zero to three genuinely reusable normal-sensitivity preferences, decisions, validated lessons, warnings, corrections, or future seeds, run one strict capture command per candidate:\nproject_root=\"$(git rev-parse --show-toplevel 2>/dev/null || pwd)\"; tree-ring --root \"$project_root/.tree-ring\" capture \"\" --event-type --ring --project \"{project}\" --agent-profile \"{agent_profile}\" --workflow-id \"{workflow_id}\" --session-id \"{session_id}\" --operation-id auto-{checkpoint_id}-<1|2|3> --source-ref agent-checkpoint:{checkpoint_id}\nUse the same indexed operation ID only when retrying that same candidate. Use only `tree-ring capture` for this automatic checkpoint; `remember` and `evidence` remain separate manual surfaces. If nothing durable occurred, write nothing and finish; never invent memory. Never store raw prompts, transcripts, tool logs, secrets, or sensitive data, and do not start a background recorder. After this checkpoint, finish the response.", + checkpoint_id = checkpoint.checkpoint_id, + project = checkpoint.project, + agent_profile = identity.agent_profile, + workflow_id = identity.workflow_id, + session_id = identity.session_id, + ); + serde_json::to_string(&json!({ + "decision": "block", + "reason": reason, + })) + .map_err(|_| "failed to serialize automatic capture checkpoint".to_string()) +} + +fn required_string<'a>(object: &'a Map, name: &str) -> Result<&'a str, String> { + object + .get(name) + .and_then(Value::as_str) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| "invalid lifecycle hook stdin".to_string()) +} + +fn normalized(label: &str, value: &str) -> String { + if value.len() <= 128 + && value + .chars() + .all(|character| character.is_ascii_alphanumeric() || "-_.:@".contains(character)) + && !matches!(value, "." | "..") + && SensitivityGuard::default().inspect(value).sensitivity == "normal" + { + value.to_string() + } else { + format!("{label}-{}", &fingerprint(value)[..24]) + } +} + +fn ensure_cwd_inside_project(project: &ActivationProject, cwd: &Path) -> Result<(), String> { + let project_root = std::fs::canonicalize(&project.project_root) + .map_err(|_| "lifecycle hook project root unavailable".to_string())?; + let cwd = + std::fs::canonicalize(cwd).map_err(|_| "lifecycle hook cwd unavailable".to_string())?; + if cwd == project_root || cwd.starts_with(&project_root) { + Ok(()) + } else { + Err("lifecycle hook cwd is outside the project".to_string()) + } +} + +fn reject_capability_fields(object: &Map) -> Result<(), String> { + const BLOCKED: &[&str] = &[ + "authorization", + "capability", + "capabilities", + "coordinator_capability", + "memory_root", + "project_root", + "store_id", + "task_hint", + ]; + if object.keys().any(|key| BLOCKED.contains(&key.as_str())) { + Err("lifecycle hook stdin contains forbidden fields".to_string()) + } else { + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + + fn fixture() -> (tempfile::TempDir, ActivationProject) { + let temp = tempfile::tempdir().unwrap(); + let project = ActivationProject::from_project_root(temp.path().join("project")); + fs::create_dir_all(project.project_root.join("src")).unwrap(); + (temp, project) + } + + #[test] + fn session_start_derives_identity_without_reading_transcript_or_prompt() { + let (_temp, project) = fixture(); + let input = json!({ + "session_id": "thread-1", + "cwd": project.project_root.join("src"), + "hook_event_name": "SessionStart", + "source": "startup", + "transcript_path": "/private/transcript.jsonl", + "model": "gpt-example" + }); + + let request = parse_lifecycle_hook(&project, "codex", &input.to_string()).unwrap(); + + assert_eq!(request.event, LifecycleHookEvent::SessionStart); + let preflight = request.preflight.unwrap(); + assert_eq!(preflight.identity.agent_profile, "codex"); + assert_eq!(preflight.identity.workflow_id, "thread-1"); + assert_eq!(preflight.identity.session_id, "thread-1"); + assert!(request.capture_checkpoint.is_none()); + } + + #[test] + fn subagent_start_derives_distinct_worker_identity() { + let (_temp, project) = fixture(); + let input = json!({ + "session_id": "thread-1", + "cwd": project.project_root, + "hook_event_name": "SubagentStart", + "agent_id": "agent-2", + "agent_type": "reviewer" + }); + + let request = parse_lifecycle_hook(&project, "claude-code", &input.to_string()).unwrap(); + + assert_eq!(request.event, LifecycleHookEvent::SubagentStart); + let preflight = request.preflight.unwrap(); + assert!(preflight + .identity + .agent_profile + .starts_with("claude-code:reviewer:")); + assert_eq!(preflight.identity.workflow_id, "thread-1"); + assert_eq!(preflight.identity.session_id, "agent-2"); + assert!(request.capture_checkpoint.is_none()); + } + + #[test] + fn stop_enforces_one_structured_checkpoint_without_reading_model_output() { + let (_temp, project) = fixture(); + let private_output = "private assistant output that must not be captured"; + let input = json!({ + "session_id": "thread-1", + "cwd": project.project_root, + "hook_event_name": "Stop", + "stop_hook_active": false, + "last_assistant_message": private_output, + "transcript_path": "/private/transcript.jsonl" + }); + + let request = parse_lifecycle_hook(&project, "codex", &input.to_string()).unwrap(); + let checkpoint = request.capture_checkpoint.unwrap(); + let rendered = render_capture_checkpoint(&checkpoint).unwrap(); + + assert_eq!(request.event, LifecycleHookEvent::Stop); + assert!(request.preflight.is_none()); + assert!(rendered.contains("\"decision\":\"block\"")); + assert!(rendered.contains(" tree-ring --root ")); + assert!(rendered.contains(" capture ")); + assert!(!rendered.contains("--scope")); + assert!(!rendered.contains(private_output)); + assert!(!rendered.contains("transcript.jsonl")); + } + + #[test] + fn active_stop_checkpoint_allows_completion_without_a_loop() { + let (_temp, project) = fixture(); + let input = json!({ + "session_id": "thread-1", + "cwd": project.project_root, + "hook_event_name": "SubagentStop", + "stop_hook_active": true, + "agent_id": "agent-2", + "agent_type": "reviewer" + }); + + let request = parse_lifecycle_hook(&project, "claude-code", &input.to_string()).unwrap(); + let checkpoint = request.capture_checkpoint.unwrap(); + + assert_eq!(request.event, LifecycleHookEvent::SubagentStop); + assert!(checkpoint + .identity + .agent_profile + .starts_with("claude-code:reviewer:")); + assert_eq!(render_capture_checkpoint(&checkpoint).unwrap(), "{}"); + } + + #[test] + fn hook_rejects_model_supplied_roots_and_capabilities() { + let (_temp, project) = fixture(); + let input = json!({ + "session_id": "thread-1", + "cwd": project.project_root, + "hook_event_name": "SessionStart", + "memory_root": "/tmp/other" + }); + + let error = parse_lifecycle_hook(&project, "codex", &input.to_string()).unwrap_err(); + assert_eq!(error, "lifecycle hook stdin contains forbidden fields"); + } +} diff --git a/crates/tree-ring-memory-cli/src/activation/manifest.rs b/crates/tree-ring-memory-cli/src/activation/manifest.rs index 4ed9d36..8ec6563 100644 --- a/crates/tree-ring-memory-cli/src/activation/manifest.rs +++ b/crates/tree-ring-memory-cli/src/activation/manifest.rs @@ -638,7 +638,7 @@ fn allowed_owned_file(harness_id: &str, path: &str) -> bool { match harness_id { "codex" => matches!( path, - ".agents/skills/tree-ring-memory/SKILL.md" | "AGENTS.md" + ".agents/skills/tree-ring-memory/SKILL.md" | ".codex/hooks.json" | "AGENTS.md" ), "claude-code" => matches!( path, diff --git a/crates/tree-ring-memory-cli/src/activation/mod.rs b/crates/tree-ring-memory-cli/src/activation/mod.rs index 3808de8..17d6ace 100644 --- a/crates/tree-ring-memory-cli/src/activation/mod.rs +++ b/crates/tree-ring-memory-cli/src/activation/mod.rs @@ -37,9 +37,14 @@ pub struct SessionIdentity { pub mod adapters; pub mod bridge; pub mod launcher; +pub mod lifecycle; pub mod manifest; pub mod preflight; +pub use lifecycle::{ + parse_lifecycle_hook, render_capture_checkpoint, render_lifecycle_hook, CaptureCheckpoint, + LifecycleHookEvent, LifecycleHookRequest, +}; #[allow(unused_imports)] pub use manifest::{ load_manifest, load_or_create_manifest, prune_receipts, write_receipt, ActivationManifest, diff --git a/crates/tree-ring-memory-cli/src/activation/preflight.rs b/crates/tree-ring-memory-cli/src/activation/preflight.rs index 4fdb6db..355c43c 100644 --- a/crates/tree-ring-memory-cli/src/activation/preflight.rs +++ b/crates/tree-ring-memory-cli/src/activation/preflight.rs @@ -54,6 +54,7 @@ enum PreflightInputContract { DirectIdentityFlags, AdapterStdin, ClaudeWrapper, + LifecycleHook, } impl PreflightRequest { @@ -105,6 +106,18 @@ impl PreflightRequest { input_contract: PreflightInputContract::ClaudeWrapper, } } + + /// Constructs a request from a validated, harness-owned lifecycle event. + /// Callers must sanitize the vendor payload before crossing this boundary. + pub(crate) fn lifecycle_hook(harness_id: impl Into, identity: SessionIdentity) -> Self { + Self { + harness_id: harness_id.into(), + identity, + task_hint: None, + context_format: PreflightContextFormat::Json, + input_contract: PreflightInputContract::LifecycleHook, + } + } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -626,6 +639,10 @@ fn validate_request_contract(request: &PreflightRequest) -> Result<(), Activatio && request.identity.workflow_id.starts_with("claude-launch-") && request.identity.session_id.starts_with("claude-session-") } + PreflightInputContract::LifecycleHook => { + matches!(request.harness_id.as_str(), "codex" | "claude-code") + && request.context_format == PreflightContextFormat::Json + } }; valid .then_some(()) diff --git a/crates/tree-ring-memory-cli/src/agent_awareness.rs b/crates/tree-ring-memory-cli/src/agent_awareness.rs index 00f31e9..b3e7fae 100644 --- a/crates/tree-ring-memory-cli/src/agent_awareness.rs +++ b/crates/tree-ring-memory-cli/src/agent_awareness.rs @@ -27,6 +27,9 @@ const SKILL_RUNTIME_ANCHOR: &str = "## When To Recall"; const CLI_RUNTIME_HEADING: &str = "Runtime bootstrap and updates:"; const CLI_RUNTIME_ANCHOR: &str = "Core commands:"; const PREFLIGHT_HEADING: &str = "## Harness Preflight"; +const AGENT_CAPTURE_HEADING: &str = "## Automatic Capture Checkpoint"; +const SKILL_CAPTURE_HEADING: &str = "## Automatic Capture Checkpoint"; +const CLI_CAPTURE_HEADING: &str = "Automatic capture checkpoint:"; const PREFLIGHT_GUIDANCE: &str = r#"## Harness Preflight Before substantive project work in a new harness session, read this canonical @@ -71,6 +74,7 @@ tree-ring init tree-ring update --check tree-ring recall "project startup warnings" tree-ring remember "Use project-scoped recall before risky changes." --event-type lesson --scope project +tree-ring capture "Use bounded lifecycle checkpoints." --event-type decision --ring cambium --project example --agent-profile worker --workflow-id workflow --session-id session --operation-id auto-checkpoint-1 --source-ref agent-checkpoint:checkpoint tree-ring evidence "Snapshot invalidation fixed stale unread chat state." --outcome promoted --evidence-ref evals/chat-state/run-042 --score 0.91 tree-ring evidence "Aggressive caching caused stale multi-chat state." --outcome rejected --evidence-ref evals/cache-branch/run-013 tree-ring forget mem_example --mode redact --reason "remove sensitive detail" @@ -118,6 +122,14 @@ Coordinated write policy: - This is operational write authorization in official Rust/CLI paths, not a read ACL or protection against an adversary who controls the local files or process environment. - Before opening a pre-v0.13 store with a v0.13/schema-v3 binary, stop every Tree Ring process, checkpoint and back up the store, and upgrade every CLI, plugin, and bundled worker. Schema v3 fences memory inserts, updates, and deletes from old v0.12 writers; all mixed-version operation is unsupported. Roll back only by restoring the backup. +Automatic capture checkpoint: + +- Maintained Codex and Claude lifecycle bridges enforce one checkpoint at `Stop` and `SubagentStop`; Agent Zero injects the same checkpoint contract through its native prompt lifecycle. +- Select zero to three concise durable normal-sensitivity candidates. Zero is correct for transient chatter, duplicates, unsupported claims, or work with no reusable outcome. +- Use `tree-ring capture` only with the lifecycle-supplied project, agent, workflow, session, indexed operation ID, and `agent-checkpoint:` source. The command fixes scope to `agent` and permits only cambium, scar, or seed candidates. +- Use only `tree-ring capture` for this automatic checkpoint. Manual `remember` and `evidence` remain separate surfaces and never bypass coordinated write policy. +- Never derive automatic memory from raw prompts, transcripts, or tool logs, and never start a background recorder. + Memory quality gates: - Before substantial project work, recall project constraints, scars, user preferences, and unresolved seeds. @@ -234,6 +246,17 @@ pub fn ensure_agent_awareness(root: &Path) -> Result Result