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"