Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ members = [
]

[workspace.package]
version = "0.10.3"
version = "0.11.0"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
2 changes: 1 addition & 1 deletion crates/tj-cli/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name = "task-journal"
path = "src/main.rs"

[dependencies]
tj-core = { package = "task-journal-core", version = "0.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 }
Expand Down
52 changes: 49 additions & 3 deletions crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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()?;
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -3150,6 +3160,7 @@ fn persist_pending_v2(
text: &str,
project_hash: &str,
backend: &str,
session_id: Option<&str>,
) -> anyhow::Result<std::path::PathBuf> {
let pending_dir = events_path
.parent()
Expand All @@ -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,
Expand All @@ -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)
Expand All @@ -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<usize> {
use tj_core::session::parser::{
extract_assistant_texts, extract_user_text, parse_session, SessionEntry,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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"));
Expand Down
1 change: 1 addition & 0 deletions crates/tj-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand Down
122 changes: 122 additions & 0 deletions crates/tj-core/src/session_id.rs
Original file line number Diff line number Diff line change
@@ -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<String> {
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<String> {
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<String> {
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);
}
}
2 changes: 1 addition & 1 deletion crates/tj-mcp/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ name = "task-journal-mcp"
path = "src/main.rs"

[dependencies]
tj-core = { package = "task-journal-core", version = "0.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 }
Expand Down
12 changes: 12 additions & 0 deletions crates/tj-mcp/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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)?;
Expand Down Expand Up @@ -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)?;
Expand Down Expand Up @@ -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)?;
Expand Down
Loading