From 6b42aa8381956f9b5de4e029b8c8eea632997b9d Mon Sep 17 00:00:00 2001 From: Mher Shahinyan Date: Fri, 12 Jun 2026 13:24:23 +0400 Subject: [PATCH] fix(hooks): stop hijacking the Claude Code session name + humanize auto-task titles (0.14.3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two related fixes for the SessionStart hook polluting the user's Claude Code session name and the task list with machine-derived junk. SessionStart envelope: drop the `sessionTitle` and `initialUserMessage` fields (the v0.10.1 "X2" experiment). `sessionTitle` was set to "TJ — ( open)", which overrode Claude Code's native, prompt-derived session name with our internal task id. `initialUserMessage` injected a "[Task Journal resumed: …]" banner into the next prompt, which the auto-open path then captured as a brand-new garbage-titled task. The resume context the model needs still rides in `additionalContext`; the tab label belongs to Claude Code. Removes the now-dead TJ_INITIAL_USER_MESSAGE gate. Auto-open titles: route `auto_open_task_from_prompt` through a new `tj_core::title::humanize_title`. It skips log lines ("685] INFO: …"), timestamps, shell prompts ("user@host:~$ …"), JSON/paths and the resume banner, and returns the first line that reads like human intent. When the prompt is only machine noise it returns None and the caller declines to auto-open — better no task than a task titled "685] INFO: Mapped {/x, POST}". Tests: new title:: unit suite (11) for the heuristic; flipped the SessionStart envelope tests to assert sessionTitle/initialUserMessage are absent; removed the obsolete TJ_INITIAL_USER_MESSAGE test; added a cli integration test proving a log-scrollback prompt does not auto-open a task. Co-Authored-By: Claude Opus 4.8 (1M context) --- CHANGELOG.md | 20 ++++ Cargo.lock | 6 +- Cargo.toml | 2 +- crates/tj-cli/src/main.rs | 92 ++++++--------- crates/tj-cli/tests/cli.rs | 158 ++++++++++--------------- crates/tj-core/src/lib.rs | 1 + crates/tj-core/src/title.rs | 186 ++++++++++++++++++++++++++++++ plugin/.claude-plugin/plugin.json | 2 +- 8 files changed, 312 insertions(+), 155 deletions(-) create mode 100644 crates/tj-core/src/title.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index b22cb38..fc676b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,26 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.14.3] - 2026-06-12 + +### Fixed +- **SessionStart no longer hijacks the Claude Code session name.** The v0.10.1 + "X2" experiment emitted a `sessionTitle` field (`TJ — ( open)`) + that overrode Claude Code's native, prompt-derived session name with our + internal task id — users saw `TJ — tj-qqay98cpc2` instead of a readable + name. It also emitted `initialUserMessage` (`[Task Journal resumed: …]`), + which the auto-open path then captured as a *new* garbage task title. Both + fields are removed; the resume context still rides in `additionalContext`, + and the tab label belongs to Claude Code again. (`TJ_INITIAL_USER_MESSAGE` + is gone with the feature.) +- **Auto-opened tasks get human-readable titles.** `auto_open_task_from_prompt` + used to take the prompt's first non-empty line verbatim, so a session that + began with terminal scrollback was titled `685] INFO: Mapped {/rest-api/…}`. + Titles now run through `humanize_title`, which skips log lines, timestamps, + shell prompts, JSON/paths and the resume banner, and picks the first line + that reads like human intent. When the prompt is *only* machine noise the + journal declines to auto-open at all rather than create a junk-titled task. + ## [0.14.2] - 2026-06-12 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index c3d35f1..de8cc64 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2166,7 +2166,7 @@ dependencies = [ [[package]] name = "task-journal-cli" -version = "0.14.2" +version = "0.14.3" dependencies = [ "anyhow", "assert_cmd", @@ -2189,7 +2189,7 @@ dependencies = [ [[package]] name = "task-journal-core" -version = "0.14.2" +version = "0.14.3" dependencies = [ "anyhow", "chrono", @@ -2213,7 +2213,7 @@ dependencies = [ [[package]] name = "task-journal-mcp" -version = "0.14.2" +version = "0.14.3" dependencies = [ "anyhow", "clap", diff --git a/Cargo.toml b/Cargo.toml index 2620050..0b50176 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,7 +7,7 @@ members = [ ] [workspace.package] -version = "0.14.2" +version = "0.14.3" edition = "2021" rust-version = "1.88" license = "MIT" diff --git a/crates/tj-cli/src/main.rs b/crates/tj-cli/src/main.rs index bc5695e..596e2df 100644 --- a/crates/tj-cli/src/main.rs +++ b/crates/tj-cli/src/main.rs @@ -1868,34 +1868,20 @@ fn main() -> Result<()> { Vec::new() }; - // v0.10.1 X2: extend SessionStart envelope with the - // undocumented `sessionTitle` + `initialUserMessage` - // fields verified in Claude Code 2.1.160's K45 Zod - // schema. additionalContext already injects the full - // pack into the system prompt; these two extras give - // the model a sharper "where were we" signal so it - // doesn't have to grep the pack to find the active - // task ID. - // - // sessionTitle: terminal tab / window label. Always - // emitted when there are open tasks — the count alone - // is useful even with no primary activity. - // - // initialUserMessage: prepended to the user's first - // real prompt this session. We only emit it when the - // primary task already has events — otherwise it's an - // unsolicited "resuming" preamble on a hollow task and - // adds noise. Gated by TJ_INITIAL_USER_MESSAGE=0 for - // tests / users who'd rather not see it. - let primary = &recent[0]; + // We deliberately DO NOT emit `sessionTitle` or + // `initialUserMessage` here. The v0.10.1 X2 experiment set + // `sessionTitle` to "TJ — ( open)", which + // OVERRODE Claude Code's native session name with our task id + // — users saw "TJ — tj-qqay98cpc2" instead of a name derived + // from their own prompt. `initialUserMessage` injected a + // "[Task Journal resumed: …]" banner into the next prompt, + // which the auto-open path then captured as a garbage task + // title. The resume context the model actually needs already + // rides in `additionalContext`; the tab label belongs to + // Claude Code, not to us. (0.14.3) let mut hook_specific = serde_json::json!({ "hookEventName": "SessionStart", "additionalContext": bundle.trim_end(), - "sessionTitle": format!( - "TJ — {} ({} open)", - primary.task_id, - recent.len(), - ), }); if !watch_paths.is_empty() { hook_specific["watchPaths"] = serde_json::Value::Array( @@ -1905,20 +1891,6 @@ fn main() -> Result<()> { .collect(), ); } - let allow_initial_user_msg = - std::env::var("TJ_INITIAL_USER_MESSAGE").as_deref() != Ok("0"); - // `task-journal create` writes an [open] event, so - // last_events is never literally empty even for a - // freshly created task. Require at least one non-open - // event so we don't inject "Resuming task X" the moment - // it was just opened with nothing to resume yet. - let has_real_events = primary.last_events.iter().any(|e| !e.starts_with("[open]")); - if allow_initial_user_msg && has_real_events { - hook_specific["initialUserMessage"] = serde_json::Value::String(format!( - "[Task Journal resumed: {} — {}]", - primary.task_id, primary.title, - )); - } let envelope = serde_json::json!({ "hookSpecificOutput": hook_specific, }); @@ -2327,8 +2299,12 @@ fn main() -> Result<()> { if auto_open_disabled || !kind.contains("UserPrompt") { return Ok(()); } - let new_task = - auto_open_task_from_prompt(&events_path, &project_hash, &conn, &text)?; + let Some(new_task) = + auto_open_task_from_prompt(&events_path, &project_hash, &conn, &text)? + else { + // Prompt was only machine noise — nothing worth a task. + return Ok(()); + }; recent.push(new_task); } @@ -3587,18 +3563,17 @@ fn auto_open_task_from_prompt( project_hash: &str, conn: &rusqlite::Connection, prompt: &str, -) -> anyhow::Result { - let cleaned = prompt.trim(); - // Title: first non-empty line, ≤80 chars. Falls back to "(empty - // prompt)" so we never write a NULL title — the classifier and - // the TUI both display titles directly. - let title: String = cleaned - .lines() - .map(|l| l.trim()) - .find(|l| !l.is_empty()) - .map(|l| l.chars().take(80).collect()) - .unwrap_or_else(|| "(auto-opened: empty prompt)".to_string()); - let goal: String = cleaned.chars().take(200).collect(); +) -> anyhow::Result> { + // Title/goal must read like a human wrote them on purpose. When the + // prompt is only machine noise — session-start scrollback + // (`685] INFO: Mapped {…}`), a shell prompt, the journal's own resume + // banner — `humanize_title` returns None and we decline to auto-open. + // Better no task than a task labelled with a log line that then leaks + // into the task list and the Claude Code session name. + let Some(title) = tj_core::title::humanize_title(prompt) else { + return Ok(None); + }; + let goal: String = tj_core::title::humanize_goal(prompt, 200).unwrap_or_else(|| title.clone()); let task_id = tj_core::new_task_id(); let mut event = tj_core::event::Event::new( @@ -3646,12 +3621,12 @@ fn auto_open_task_from_prompt( } } - Ok(tj_core::classifier::TaskContext { + Ok(Some(tj_core::classifier::TaskContext { task_id, title, last_events: vec![], constraints: vec![], - }) + })) } fn persist_pending(events_path: &std::path::Path, text: &str, err: &str) -> anyhow::Result<()> { @@ -3989,7 +3964,12 @@ fn process_pending_entry( std::fs::remove_file(path)?; return Ok(()); } - let new_task = auto_open_task_from_prompt(events_path, project_hash, &conn, &text)?; + let Some(new_task) = auto_open_task_from_prompt(events_path, project_hash, &conn, &text)? + else { + // Prompt was only machine noise — drop the entry silently. + std::fs::remove_file(path)?; + return Ok(()); + }; recent.push(new_task); } diff --git a/crates/tj-cli/tests/cli.rs b/crates/tj-cli/tests/cli.rs index 555fb2a..406684c 100644 --- a/crates/tj-cli/tests/cli.rs +++ b/crates/tj-cli/tests/cli.rs @@ -1293,43 +1293,26 @@ fn ingest_hook_session_start_emits_resume_pack_json() { ctx.contains("Adopt Rust"), "additionalContext must include event text: {ctx}" ); - // v0.10.1 X2: sessionTitle is the Claude Code tab/window label. - // Always emitted when there are open tasks. Format: "TJ — ( open)". - let title = hso - .get("sessionTitle") - .and_then(|s| s.as_str()) - .expect("sessionTitle key missing in v0.10.1+"); - assert!( - title.starts_with("TJ — "), - "sessionTitle must lead with 'TJ — ' marker: {title}" - ); - assert!( - title.contains("(1 open)"), - "sessionTitle must include the open-task count: {title}" - ); - // initialUserMessage emitted only when the primary task has at - // least one event — this task has the decision event, so we - // expect it. Format: "[Task Journal resumed: ]". - let initial = hso - .get("initialUserMessage") - .and_then(|s| s.as_str()) - .expect("initialUserMessage key missing when primary task has events"); + // 0.14.3: sessionTitle / initialUserMessage are no longer emitted — + // they overrode Claude Code's native session name and seeded garbage + // task titles. The resume context rides in additionalContext alone. assert!( - initial.contains("Task Journal resumed"), - "initialUserMessage must announce resume: {initial}" + hso.get("sessionTitle").is_none(), + "sessionTitle must NOT be emitted — it overrides Claude's session name; got: {hso}" ); assert!( - initial.contains("Wire SessionStart pack"), - "initialUserMessage must include primary task title: {initial}" + hso.get("initialUserMessage").is_none(), + "initialUserMessage must NOT be emitted; got: {hso}" ); } #[test] -fn session_start_emits_no_initial_user_message_for_hollow_task() { - // v0.10.1 X2: hollow task (created but no events) should NOT - // trigger initialUserMessage — that would inject an unsolicited - // "resuming" preamble into the user's first prompt on a task - // that has nothing to resume. sessionTitle still emitted. +fn session_start_emits_neither_session_title_nor_initial_message() { + // 0.14.3: the SessionStart envelope carries ONLY additionalContext + // (+ optional watchPaths). It must never set sessionTitle (which + // overrode Claude Code's own session name with our task id) nor + // initialUserMessage (which seeded garbage "[Task Journal resumed: …]" + // task titles). let dir = assert_fs::TempDir::new().unwrap(); Command::cargo_bin("task-journal") .unwrap() @@ -1353,71 +1336,12 @@ fn session_start_emits_no_initial_user_message_for_hollow_task() { let v: serde_json::Value = serde_json::from_str(body.trim()).unwrap(); let hso = v.get("hookSpecificOutput").unwrap(); assert!( - hso.get("sessionTitle").is_some(), - "sessionTitle still emitted on hollow task" + hso.get("sessionTitle").is_none(), + "sessionTitle must NOT be emitted; got: {hso}" ); assert!( hso.get("initialUserMessage").is_none(), - "initialUserMessage MUST NOT be set when primary task has no events; got: {hso}" - ); -} - -#[test] -fn session_start_initial_user_message_disabled_via_env() { - // TJ_INITIAL_USER_MESSAGE=0 opt-out for users who don't want - // the resume preamble injected into their first prompt. - let dir = assert_fs::TempDir::new().unwrap(); - let task_id = String::from_utf8( - Command::cargo_bin("task-journal") - .unwrap() - .env("XDG_DATA_HOME", dir.path()) - .args(["create", "Env-disabled initial message"]) - .assert() - .success() - .get_output() - .stdout - .clone(), - ) - .unwrap() - .trim() - .to_string(); - - Command::cargo_bin("task-journal") - .unwrap() - .env("XDG_DATA_HOME", dir.path()) - .args([ - "event", - &task_id, - "--type", - "finding", - "--text", - "Sanity event so the task is not hollow.", - ]) - .assert() - .success(); - - let body = String::from_utf8( - Command::cargo_bin("task-journal") - .unwrap() - .env("XDG_DATA_HOME", dir.path()) - .env("TJ_INITIAL_USER_MESSAGE", "0") - .args(["ingest-hook", "--kind", "SessionStart", "--text", ""]) - .assert() - .success() - .get_output() - .stdout - .clone(), - ) - .unwrap(); - let v: serde_json::Value = serde_json::from_str(body.trim()).unwrap(); - let hso = v.get("hookSpecificOutput").unwrap(); - assert!( - hso.get("initialUserMessage").is_none(), - "TJ_INITIAL_USER_MESSAGE=0 must suppress initialUserMessage" - ); - assert!( - hso.get("additionalContext").is_some(), - "additionalContext must still be present — env var only gates initialUserMessage" + "initialUserMessage must NOT be emitted; got: {hso}" ); } @@ -2087,6 +2011,52 @@ fn ingest_hook_auto_opens_task_when_no_open_tasks() { .stdout(contains("**Goal**: implement FIN-868 paygate fee dedup")); } +#[test] +fn ingest_hook_does_not_auto_open_for_log_scrollback() { + // 0.14.3: a UserPromptSubmit whose text is only terminal scrollback — + // here a framework log line — must NOT auto-open a task, otherwise the + // journal fills with garbage titles like "685] INFO: Mapped {…}" that + // then leak into the task list and the Claude Code session name. + let dir = assert_fs::TempDir::new().unwrap(); + let payload = serde_json::json!({ + "hook_event_name": "UserPromptSubmit", + "session_id": "s-noise", + "transcript_path": "/tmp/x", + "cwd": "/tmp", + "prompt": "685] INFO: Mapped {/rest-api/paymentlnk-notify, POST} route" + }) + .to_string(); + + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .env("TJ_CLASSIFIER_CLI", "/bin/false") + .env("TJ_INGEST_SYNC", "1") + .args(["ingest-hook", "--backend", "hybrid"]) + .write_stdin(payload) + .assert() + .success(); + + // Nothing should have been indexed — search for a token from the log + // line must surface no task id. + let body = String::from_utf8( + Command::cargo_bin("task-journal") + .unwrap() + .env("XDG_DATA_HOME", dir.path()) + .args(["search", "paymentlnk"]) + .assert() + .success() + .get_output() + .stdout + .clone(), + ) + .unwrap(); + assert!( + !body.lines().any(|l| l.trim().starts_with("tj-")), + "log-scrollback prompt must NOT auto-open a task; got: {body:?}" + ); +} + #[test] fn reopen_command_flips_status_back_to_open() { // v0.5.0 Phase C: a closed task can be revived via `reopen`. The @@ -3358,8 +3328,8 @@ fn session_start_omits_watch_paths_when_disabled_via_env() { "TJ_WATCH_PATHS=0 must suppress watchPaths emission" ); assert!( - v["hookSpecificOutput"]["sessionTitle"].is_string(), - "sessionTitle still emitted independently" + v["hookSpecificOutput"]["additionalContext"].is_string(), + "additionalContext still emitted independently of watchPaths" ); } diff --git a/crates/tj-core/src/lib.rs b/crates/tj-core/src/lib.rs index fc813cb..2ec5d50 100644 --- a/crates/tj-core/src/lib.rs +++ b/crates/tj-core/src/lib.rs @@ -63,6 +63,7 @@ pub mod reminder; pub mod session; pub mod session_id; pub mod storage; +pub mod title; #[cfg(test)] mod schema_version_tests { diff --git a/crates/tj-core/src/title.rs b/crates/tj-core/src/title.rs new file mode 100644 index 0000000..3d06c5c --- /dev/null +++ b/crates/tj-core/src/title.rs @@ -0,0 +1,186 @@ +//! Human-readable title derivation for auto-opened tasks. +//! +//! When the journal auto-opens a task it has only a raw chat chunk to name it +//! with. Early versions took the first non-empty line verbatim — which, at +//! session start, is often terminal scrollback (`685] INFO: Mapped {…}`), a +//! shell prompt (`user@host:~$ …`), or the journal's own resume banner +//! (`[Task Journal resumed: …]`). Those leak into the task list and the +//! Claude Code session name. +//! +//! `humanize_title` scans for the first line that looks like natural-language +//! intent and returns it cleaned + truncated. When nothing qualifies it +//! returns `None` so the caller declines to auto-open rather than label a task +//! with machine noise. + +/// Pick the first natural-language line from `raw` as a task title. +/// Returns `None` when the input is only logs / banners / shell prompts / JSON. +pub fn humanize_title(raw: &str) -> Option<String> { + intent_line(raw).map(|l| truncate(l, 80)) +} + +/// Like [`humanize_title`] but truncated to `max` chars — used for the task +/// goal, which tolerates a longer sentence than the 80-char title. +pub fn humanize_goal(raw: &str, max: usize) -> Option<String> { + intent_line(raw).map(|l| truncate(l, max)) +} + +/// First line of `raw` that reads like a human wrote it on purpose. +fn intent_line(raw: &str) -> Option<&str> { + raw.lines().map(str::trim).find(|l| is_human_intent(l)) +} + +fn is_human_intent(line: &str) -> bool { + let l = line.trim(); + if l.chars().count() < 6 { + return false; + } + // Slash command, shell line, markdown heading, JSON/array/tag, log timestamp. + if let Some(c) = l.chars().next() { + if matches!(c, '/' | '$' | '#' | '{' | '[' | '<' | '|' | '`') { + return false; + } + } + if l.starts_with("http://") || l.starts_with("https://") { + return false; + } + if l.contains("Task Journal resumed") { + return false; + } + if looks_like_log(l) || looks_like_shell_prompt(l) { + return false; + } + // Real intent: has letters and at least two whitespace-separated words. + l.chars().any(char::is_alphabetic) && l.split_whitespace().count() >= 2 +} + +/// `685] INFO: Mapped …`, `… INFO: …`, `… ERROR: …` etc. +fn looks_like_log(l: &str) -> bool { + // "<digits>] " near the start (a numeric log index). + if let Some(rb) = l.find(']') { + if rb > 0 && rb <= 8 && l[..rb].chars().all(|c| c.is_ascii_digit()) { + return true; + } + } + const LEVELS: [&str; 5] = ["INFO:", "WARN:", "ERROR:", "DEBUG:", "TRACE:"]; + LEVELS.iter().any(|lvl| l.contains(lvl)) +} + +/// `shahinyanm@DESKTOP-KM9V32O:~/docker-local-env$ claude …` +fn looks_like_shell_prompt(l: &str) -> bool { + let Some(at) = l.find('@') else { + return false; + }; + if at >= 40 { + return false; + } + let after = &l[at + 1..]; + after.contains(":~") || after.contains(":/") || after.contains("$ ") || after.contains("# ") +} + +/// Char-safe truncate with an ellipsis when cut. +fn truncate(s: &str, max: usize) -> String { + if s.chars().count() <= max { + return s.to_string(); + } + let cut: String = s.chars().take(max.saturating_sub(1)).collect(); + format!("{}…", cut.trim_end()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rejects_numeric_log_line() { + assert_eq!( + humanize_title("685] INFO: Mapped {/rest-api/paymentlnk-notify, POST} route"), + None + ); + } + + #[test] + fn rejects_timestamped_log_line() { + assert_eq!( + humanize_title("[11:13:30.685] INFO: Mapped {/rest-api/qiwi-notify, POST}"), + None + ); + } + + #[test] + fn rejects_resume_banner() { + assert_eq!( + humanize_title("[Task Journal resumed: tj-ma8393mg0d — Так, давай ты сделаешь match]"), + None + ); + } + + #[test] + fn rejects_shell_prompt() { + assert_eq!( + humanize_title( + "shahinyanm@DESKTOP-KM9V32O:~/docker-local-env$ claude plugin marketplace update" + ), + None + ); + } + + #[test] + fn rejects_json_and_paths() { + assert_eq!(humanize_title("{\"command\":\"task-journal\"}"), None); + assert_eq!(humanize_title("/home/shahinyanm/www/claude-memory"), None); + } + + #[test] + fn accepts_plain_prose() { + assert_eq!( + humanize_title("Fix the auth bug in the payment middleware"), + Some("Fix the auth bug in the payment middleware".to_string()) + ); + } + + #[test] + fn accepts_russian_prose() { + assert_eq!( + humanize_title("Сделай так чтобы имя сессии не ломалось"), + Some("Сделай так чтобы имя сессии не ломалось".to_string()) + ); + } + + #[test] + fn picks_first_human_line_after_noise() { + let raw = "685] INFO: Mapped {/x, POST}\n\ + shahinyanm@host:~$ ls\n\ + Add validation for negative order amounts"; + assert_eq!( + humanize_title(raw), + Some("Add validation for negative order amounts".to_string()) + ); + } + + #[test] + fn truncates_long_title_to_80_chars() { + let long = "Implement ".repeat(20); + let out = humanize_title(&long).unwrap(); + assert!( + out.chars().count() <= 80, + "got {} chars", + out.chars().count() + ); + assert!(out.ends_with('…')); + } + + #[test] + fn none_when_only_noise() { + let raw = "685] INFO: a\n$ git status\n/clear\n{\"k\":1}"; + assert_eq!(humanize_title(raw), None); + } + + #[test] + fn goal_allows_longer_text() { + let line = "Make sure every coding session always records its reasoning chain \ + into the task journal without spawning a model"; + let goal = humanize_goal(line, 200).unwrap(); + assert!(goal.chars().count() > 80); + assert!(goal.starts_with("Make sure every coding session")); + } +} diff --git a/plugin/.claude-plugin/plugin.json b/plugin/.claude-plugin/plugin.json index d2e60b6..247dadc 100644 --- a/plugin/.claude-plugin/plugin.json +++ b/plugin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "task-journal", - "version": "0.14.2", + "version": "0.14.3", "description": "Append-only journal of AI-coding task reasoning chains: hypotheses, decisions, rejections, evidence. Renders compact resume packs so an agent can pick up a 2-week-old task with full context.", "author": { "name": "Mher Shahinyan"