diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d25a6f..f0fb15a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,29 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.11.0] - 2026-06-08 + +**Live `session_id` on emitted events (additive, opt-in).** The journal now +stamps the active Claude Code session id onto the events it emits itself — +hook-driven events (synchronous FileChanged/PreCompact and the async +classify-worker path) and the MCP tools (`task_create`, `event_add`, +`task_close`). This lets external consumers correlate journal events with +the originating session without time-window heuristics. + +Fully backward-compatible: the id is read from the hook payload's +`session_id` field, falling back to the `CLAUDE_CODE_SESSION_ID` env var. +When neither is present (standalone use), nothing is added and behavior is +byte-identical to before. This is distinct from the existing transcript +`session_id` *parsing* — that passive read-only lookup is unchanged. + +### Added +- `tj_core::session_id` — helpers to resolve the live session id + (`live_session_id`, `session_id_from_payload`, `session_id_from_env`) and + additively stamp it into an event's free-form `meta` (`stamp_session_id`). +- `meta.session_id` on live hook events and MCP events when a source is + available. The pending-v2 chunk now carries `session_id` so async + classify-worker events inherit it. + ## [0.10.3] - 2026-06-06 **Search & pack quality fixes from real user feedback.** Five bugs hit diff --git a/Cargo.lock b/Cargo.lock index e82c2c8..5493d34 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2166,7 +2166,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.10.3" +version = "0.11.0" dependencies = [ "anyhow", "assert_cmd", @@ -2189,7 +2189,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.10.3" +version = "0.11.0" dependencies = [ "anyhow", "chrono", @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.10.3" +version = "0.11.0" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index cafb09c..f635ce9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.10.3" +version = "0.11.0" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/Cargo.toml b/crates/tj-cli/Cargo.toml index 7afb114..35031cc 100644 --- a/crates/tj-cli/Cargo.toml +++ b/crates/tj-cli/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.10.3", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.11.0", path = "../tj-core" } anyhow = { workspace = true } clap = { workspace = true } tracing = { workspace = true } diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index 0927e30..3be3306 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1512,6 +1512,12 @@ fn main() -> Result<()> { let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl")); std::fs::create_dir_all(events_path.parent().unwrap())?; + // Live Claude Code session id (hook payload → env fallback), + // stamped additively onto the live events this hook emits so + // consumers can correlate them with the session. None when + // neither source is present (standalone behaviour unchanged). + let live_session_id = tj_core::session_id::live_session_id(Some(&payload)); + // SessionStart: emit a JSON envelope with compact resume-packs of // open tasks so Claude Code injects them into its system context // automatically. This is the load-bearing UX for "the journal @@ -1680,6 +1686,7 @@ fn main() -> Result<()> { ); event.confidence = Some(0.9); event.status = tj_core::event::EventStatus::Confirmed; + tj_core::session_id::stamp_session_id(&mut event.meta, live_session_id.as_deref()); let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?; writer.flush_durable()?; @@ -1732,6 +1739,7 @@ fn main() -> Result<()> { &backend, last_event_ts.as_deref(), "PreCompactChunk", + live_session_id.as_deref(), ) .unwrap_or(0); if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() { @@ -1793,6 +1801,7 @@ fn main() -> Result<()> { ); event.confidence = Some(1.0); event.status = tj_core::event::EventStatus::Confirmed; + tj_core::session_id::stamp_session_id(&mut event.meta, live_session_id.as_deref()); let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?; writer.flush_durable()?; @@ -1865,6 +1874,7 @@ fn main() -> Result<()> { &backend, last_event_ts.as_deref(), "StopChunk", + live_session_id.as_deref(), ) .unwrap_or(0); if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() { @@ -1947,7 +1957,7 @@ fn main() -> Result<()> { .unwrap_or(false); let is_mock = mock_event_type.is_some() && mock_task_id.is_some(); if !is_mock && !force_sync { - let _ = persist_pending_v2(&events_path, &kind, &text, &project_hash, &backend)?; + let _ = persist_pending_v2(&events_path, &kind, &text, &project_hash, &backend, live_session_id.as_deref())?; // Fire-and-forget worker. Errors here are best-effort — // a failure to spawn just means the entry sits in // pending/ until the next hook fires another spawn. @@ -3150,6 +3160,7 @@ fn persist_pending_v2( text: &str, project_hash: &str, backend: &str, + session_id: Option<&str>, ) -> anyhow::Result { let pending_dir = events_path .parent() @@ -3159,7 +3170,7 @@ fn persist_pending_v2( .join("pending"); std::fs::create_dir_all(&pending_dir)?; let id = ulid::Ulid::new().to_string(); - let payload = serde_json::json!({ + let mut payload = serde_json::json!({ "schema": "v2", "kind": kind, "text": text, @@ -3168,6 +3179,9 @@ fn persist_pending_v2( "backend": backend, "queued_at": chrono::Utc::now().to_rfc3339(), }); + if let Some(sid) = session_id { + payload["session_id"] = serde_json::Value::String(sid.to_string()); + } let path = pending_dir.join(format!("{id}.json")); std::fs::write(&path, serde_json::to_string_pretty(&payload)?)?; Ok(path) @@ -3194,6 +3208,7 @@ fn enqueue_transcript_chunks_since_last_event( backend: &str, last_event_ts: Option<&str>, assistant_chunk_kind: &str, + session_id: Option<&str>, ) -> anyhow::Result { use tj_core::session::parser::{ extract_assistant_texts, extract_user_text, parse_session, SessionEntry, @@ -3226,7 +3241,7 @@ fn enqueue_transcript_chunks_since_last_event( continue; } } - persist_pending_v2(events_path, kind, &text, project_hash, backend)?; + persist_pending_v2(events_path, kind, &text, project_hash, backend, session_id)?; count += 1; } Ok(count) @@ -3394,6 +3409,9 @@ fn process_pending_entry( .unwrap_or("") .to_string(); + // Inherit the session id queued on the v2 chunk (additive; absent → None). + let chunk_session_id = tj_core::session_id::session_id_from_payload(&v); + // Mirror the synchronous flow that used to live in IngestHook — // see commit history of v0.6.1 for the original. Auto-open, run // classifier, apply integrity safeguards, persist event, telemetry. @@ -3510,6 +3528,7 @@ fn process_pending_entry( event.confidence = Some(confidence); event.status = tj_core::classifier::decide_status(confidence); event.evidence_strength = evidence_strength; + tj_core::session_id::stamp_session_id(&mut event.meta, chunk_session_id.as_deref()); let mut writer = tj_core::storage::JsonlWriter::open(events_path)?; writer.append(&event)?; @@ -3686,6 +3705,33 @@ mod inline_tests { // declared before this module begins. use super::*; + #[test] + fn persist_pending_v2_includes_session_id_when_present() { + let dir = tempfile::tempdir().unwrap(); + let events_path = dir.path().join("events").join("h.jsonl"); + std::fs::create_dir_all(events_path.parent().unwrap()).unwrap(); + let p = persist_pending_v2(&events_path, "PostToolUse", "txt", "h", "hybrid", Some("sess-9")) + .unwrap(); + let body = std::fs::read_to_string(&p).unwrap(); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert_eq!(v["session_id"], serde_json::json!("sess-9")); + assert_eq!( + tj_core::session_id::session_id_from_payload(&v).as_deref(), + Some("sess-9") + ); + } + + #[test] + fn persist_pending_v2_omits_session_id_when_none() { + let dir = tempfile::tempdir().unwrap(); + let events_path = dir.path().join("events").join("h.jsonl"); + std::fs::create_dir_all(events_path.parent().unwrap()).unwrap(); + let p = persist_pending_v2(&events_path, "PostToolUse", "txt", "h", "hybrid", None).unwrap(); + let body = std::fs::read_to_string(&p).unwrap(); + let v: serde_json::Value = serde_json::from_str(&body).unwrap(); + assert!(v.get("session_id").is_none()); + } + #[test] fn is_rewind_prompt_simple() { assert!(is_rewind_prompt("/rewind")); diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index 90a1f19..42d7a27 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -56,6 +56,7 @@ pub mod pack; pub mod paths; pub mod project_hash; pub mod session; +pub mod session_id; pub mod storage; #[cfg(test)] diff --git a/crates/tj-core/src/session_id.rs b/crates/tj-core/src/session_id.rs new file mode 100644 index 0000000..e561345 --- /dev/null +++ b/crates/tj-core/src/session_id.rs @@ -0,0 +1,122 @@ +//! Live Claude Code session id helpers. +//! +//! task-journal already *parses* session ids out of Claude Code +//! transcripts (`session::parser`) — that is a passive, read-only +//! lookup of someone else's identifier. This module is the other +//! direction: additively stamping the live session id onto the events +//! the journal itself emits (hooks + MCP tools), so downstream +//! consumers can correlate those events with the originating session +//! without time-window heuristics. +//! +//! Source order: hook payload field `session_id` → `CLAUDE_CODE_SESSION_ID` +//! env var → `None`. `None` means standalone behaviour is unchanged — +//! nothing is added to `meta`. + +use serde_json::Value; + +/// Pull `session_id` out of a Claude Code hook payload (or a pending-v2 +/// chunk, which carries the same field). Empty strings count as absent. +pub fn session_id_from_payload(payload: &Value) -> Option { + payload + .get("session_id") + .and_then(|s| s.as_str()) + .filter(|s| !s.is_empty()) + .map(str::to_string) +} + +/// Read `CLAUDE_CODE_SESSION_ID` from the environment. Empty counts as absent. +pub fn session_id_from_env() -> Option { + std::env::var("CLAUDE_CODE_SESSION_ID") + .ok() + .filter(|s| !s.is_empty()) +} + +/// Resolve the live session id: hook payload first, env var as fallback. +/// `None` when neither source provides one (standalone — caller adds nothing). +pub fn live_session_id(payload: Option<&Value>) -> Option { + payload + .and_then(session_id_from_payload) + .or_else(session_id_from_env) +} + +/// Additively record `session_id` into a free-form `meta` value. +/// +/// No-op when `sid` is `None` or `meta` is not a JSON object. Never +/// overwrites or removes existing keys — additive by construction. +pub fn stamp_session_id(meta: &mut Value, sid: Option<&str>) { + if let (Some(sid), Some(obj)) = (sid, meta.as_object_mut()) { + obj.insert("session_id".to_string(), Value::String(sid.to_string())); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + use std::sync::Mutex; + + // Serialises the env-touching tests — std env is process-global. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + #[test] + fn payload_session_id_extracted() { + let p = json!({"session_id": "abc-123", "hook_event_name": "PostToolUse"}); + assert_eq!(session_id_from_payload(&p).as_deref(), Some("abc-123")); + } + + #[test] + fn payload_empty_or_missing_is_none() { + assert_eq!(session_id_from_payload(&json!({"session_id": ""})), None); + assert_eq!(session_id_from_payload(&json!({})), None); + assert_eq!(session_id_from_payload(&Value::Null), None); + } + + #[test] + fn stamp_adds_to_object_meta() { + let mut meta = json!({"title": "Goal"}); + stamp_session_id(&mut meta, Some("s-1")); + assert_eq!(meta["session_id"], json!("s-1")); + assert_eq!(meta["title"], json!("Goal")); + } + + #[test] + fn stamp_none_is_noop() { + let mut meta = json!({"title": "Goal"}); + stamp_session_id(&mut meta, None); + assert!(meta.get("session_id").is_none()); + } + + #[test] + fn stamp_on_non_object_is_noop() { + let mut meta = Value::Null; + stamp_session_id(&mut meta, Some("s-1")); + assert_eq!(meta, Value::Null); + } + + #[test] + fn live_payload_wins_over_env() { + let _g = ENV_LOCK.lock().unwrap(); + std::env::set_var("CLAUDE_CODE_SESSION_ID", "from-env"); + let p = json!({"session_id": "from-payload"}); + assert_eq!(live_session_id(Some(&p)).as_deref(), Some("from-payload")); + std::env::remove_var("CLAUDE_CODE_SESSION_ID"); + } + + #[test] + fn live_falls_back_to_env() { + let _g = ENV_LOCK.lock().unwrap(); + std::env::set_var("CLAUDE_CODE_SESSION_ID", "from-env"); + let p = json!({"hook_event_name": "Stop"}); + assert_eq!(live_session_id(Some(&p)).as_deref(), Some("from-env")); + assert_eq!(live_session_id(None).as_deref(), Some("from-env")); + std::env::remove_var("CLAUDE_CODE_SESSION_ID"); + } + + #[test] + fn live_none_when_no_source() { + let _g = ENV_LOCK.lock().unwrap(); + std::env::remove_var("CLAUDE_CODE_SESSION_ID"); + assert_eq!(live_session_id(None), None); + assert_eq!(live_session_id(Some(&json!({}))), None); + } +} diff --git a/crates/tj-mcp/Cargo.toml b/crates/tj-mcp/Cargo.toml index 6a8ac41..f62e0db 100644 --- a/crates/tj-mcp/Cargo.toml +++ b/crates/tj-mcp/Cargo.toml @@ -16,7 +16,7 @@ name = "task-journal-mcp" path = "src/main.rs" [dependencies] -tj-core = { package = "task-journal-core", version = "0.10.3", path = "../tj-core" } +tj-core = { package = "task-journal-core", version = "0.11.0", path = "../tj-core" } anyhow = { workspace = true } tokio = { workspace = true } tracing = { workspace = true } diff --git a/crates/tj-mcp/src/main.rs b/crates/tj-mcp/src/main.rs index cfaa5c7..8a73ca1 100644 --- a/crates/tj-mcp/src/main.rs +++ b/crates/tj-mcp/src/main.rs @@ -431,6 +431,10 @@ impl TaskJournalServer { p.initial_context.clone().unwrap_or_else(|| p.title.clone()), ); event.meta = serde_json::json!({"title": p.title.clone()}); + tj_core::session_id::stamp_session_id( + &mut event.meta, + tj_core::session_id::session_id_from_env().as_deref(), + ); let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?; @@ -483,6 +487,10 @@ impl TaskJournalServer { ); event.corrects = p.corrects.clone(); event.supersedes = p.supersedes.clone(); + tj_core::session_id::stamp_session_id( + &mut event.meta, + tj_core::session_id::session_id_from_env().as_deref(), + ); let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?; @@ -557,6 +565,10 @@ impl TaskJournalServer { meta.insert("outcome_tag".into(), serde_json::Value::String(t.clone())); } event.meta = serde_json::Value::Object(meta); + tj_core::session_id::stamp_session_id( + &mut event.meta, + tj_core::session_id::session_id_from_env().as_deref(), + ); let mut writer = tj_core::storage::JsonlWriter::open(&events_path)?; writer.append(&event)?;