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
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 — <task_id> (<n> 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
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.14.2"
version = "0.14.3"
edition = "2021"
rust-version = "1.88"
license = "MIT"
Expand Down
92 changes: 36 additions & 56 deletions crates/tj-cli/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 — <task_id> (<n> 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(
Expand All @@ -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,
});
Expand Down Expand Up @@ -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);
}

Expand Down Expand Up @@ -3587,18 +3563,17 @@ fn auto_open_task_from_prompt(
project_hash: &str,
conn: &rusqlite::Connection,
prompt: &str,
) -> anyhow::Result<tj_core::classifier::TaskContext> {
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<Option<tj_core::classifier::TaskContext>> {
// 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(
Expand Down Expand Up @@ -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<()> {
Expand Down Expand Up @@ -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);
}

Expand Down
158 changes: 64 additions & 94 deletions crates/tj-cli/tests/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 — <id> (<n> 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: <id> — <title>]".
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()
Expand All @@ -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}"
);
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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"
);
}

Expand Down
Loading
Loading