Skip to content

Commit eb88a2b

Browse files
Shahinyanmclaude
andcommitted
feat(v0.9.3): Stop hook transcript catch-up
Replace the v0.7.0-era hardcoded `--kind=Stop --text="Session ended"` hook command with the same transcript-tail catch-up that PreCompact already does. The old form queued one unclassifiable noise entry per session end; v0.9.3 reads the JSONL session log and enqueues user + assistant entries newer than the active task's last event timestamp. Implementation: rename precompact_enqueue_transcript_chunks → enqueue_transcript_chunks_since_last_event with a new `assistant_chunk_kind` parameter, then call from both PreCompact (`PreCompactChunk`) and Stop (`StopChunk`) branches. Distinct kinds let ops grep pending/ by source hook. Mock test paths (`--mock-event-type` + `--mock-task-id`) bypass the new Stop branch so existing test fixtures invoking `--kind=Stop` with mock args still hit the mock-classifier dispatch unchanged. Plugin hooks.json Stop entry drops the explicit `--kind=Stop --text="Session ended"` flags and reads the hook stdin payload like PostToolUse and PreCompact. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent be9610f commit eb88a2b

10 files changed

Lines changed: 241 additions & 16 deletions

File tree

CHANGELOG.md

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
77

88
## [Unreleased]
99

10+
## [0.9.3] - 2026-05-17
11+
12+
**Stop hook learns to catch up.** Previously the `Stop` hook fired
13+
with a hardcoded `--text="Session ended"` — a sentinel string that
14+
carried no semantic signal and just littered the pending queue with
15+
unclassifiable noise (the heuristic skipped it, the API would have
16+
spent a haiku call to say "this is nothing"). v0.9.3 replaces it with
17+
the same transcript-tail catch-up that PreCompact already does:
18+
parse the JSONL session log, enqueue user + assistant entries newer
19+
than the active task's last event timestamp, spawn the
20+
classify-worker. No boundary marker — a session end isn't a
21+
reasoning boundary, just a pause.
22+
23+
### Added
24+
- Stop-hook transcript catch-up. Mirrors the PreCompact catch-up
25+
introduced in v0.7.1. Reads `transcript_path` from the hook stdin
26+
payload; chunks land as `UserPromptSubmit` (user) or `StopChunk`
27+
(assistant) pending v2 entries. Distinct `StopChunk` kind lets ops
28+
grep the pending dir by source hook.
29+
- `enqueue_transcript_chunks_since_last_event` helper — extracted
30+
from the PreCompact branch so both hooks share the same code path.
31+
Old `precompact_enqueue_transcript_chunks` was renamed; same body,
32+
one new parameter (`assistant_chunk_kind`).
33+
34+
### Changed
35+
- Plugin `hooks.json` Stop entry no longer pins
36+
`--kind=Stop --text="Session ended"`. Reads hook stdin payload
37+
like PostToolUse and PreCompact already do.
38+
39+
### Compatibility
40+
- Mock test path (`--mock-event-type` + `--mock-task-id`) bypasses
41+
the new Stop branch so existing test fixtures invoking
42+
`--kind=Stop` with mock args still hit the mock-classifier
43+
dispatch instead of being intercepted by transcript catch-up.
44+
1045
## [0.9.2] - 2026-05-17
1146

1247
### Fixed

Cargo.lock

Lines changed: 3 additions & 3 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ members = [
77
]
88

99
[workspace.package]
10-
version = "0.9.2"
10+
version = "0.9.3"
1111
edition = "2021"
1212
rust-version = "1.88"
1313
license = "MIT"

crates/tj-cli/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.9.2", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.9.3", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
clap = { workspace = true }
2222
tracing = { workspace = true }

crates/tj-cli/src/main.rs

Lines changed: 79 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1587,12 +1587,13 @@ fn main() -> Result<()> {
15871587
.map(std::path::PathBuf::from);
15881588
if let Some(tp) = transcript_path.as_ref() {
15891589
if tp.exists() {
1590-
let enq = precompact_enqueue_transcript_chunks(
1590+
let enq = enqueue_transcript_chunks_since_last_event(
15911591
tp,
15921592
&events_path,
15931593
&project_hash,
15941594
&backend,
15951595
last_event_ts.as_deref(),
1596+
"PreCompactChunk",
15961597
)
15971598
.unwrap_or(0);
15981599
if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() {
@@ -1637,6 +1638,66 @@ fn main() -> Result<()> {
16371638
return Ok(());
16381639
}
16391640

1641+
// Stop: Claude Code is about to end the session. Same
1642+
// catch-up logic as PreCompact (read transcript tail,
1643+
// enqueue chunks newer than the active task's last
1644+
// event timestamp), but no boundary marker — a session
1645+
// end isn't a reasoning boundary, the task is just
1646+
// pausing. The v0.7.0-era Stop hook fired with hardcoded
1647+
// text="Session ended" which carried no signal and just
1648+
// littered the pending queue with noise; v0.9.3 replaces
1649+
// that with a real catch-up.
1650+
//
1651+
// Skip the catch-up when running through the mock test
1652+
// path (mock_event_type + mock_task_id) — those tests
1653+
// expect their explicit `--kind=Stop` invocation to fall
1654+
// through to the mock-classifier dispatch below, not be
1655+
// intercepted by the new transcript-tail logic.
1656+
let is_mock_stop = mock_event_type.is_some() && mock_task_id.is_some();
1657+
if !is_mock_stop && kind == "Stop" {
1658+
if !events_path.exists() {
1659+
return Ok(());
1660+
}
1661+
let state_path =
1662+
tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite"));
1663+
let conn = tj_core::db::open(&state_path)?;
1664+
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
1665+
let recent = recent_task_contexts(&conn, 1)?;
1666+
let Some(tc) = recent.into_iter().next() else {
1667+
return Ok(());
1668+
};
1669+
1670+
let last_event_ts: Option<String> = conn
1671+
.query_row(
1672+
"SELECT timestamp FROM events_index WHERE task_id=?1 \
1673+
ORDER BY timestamp DESC LIMIT 1",
1674+
rusqlite::params![&tc.task_id],
1675+
|r| r.get::<_, String>(0),
1676+
)
1677+
.ok();
1678+
let transcript_path = payload
1679+
.get("transcript_path")
1680+
.and_then(|x| x.as_str())
1681+
.map(std::path::PathBuf::from);
1682+
if let Some(tp) = transcript_path.as_ref() {
1683+
if tp.exists() {
1684+
let enq = enqueue_transcript_chunks_since_last_event(
1685+
tp,
1686+
&events_path,
1687+
&project_hash,
1688+
&backend,
1689+
last_event_ts.as_deref(),
1690+
"StopChunk",
1691+
)
1692+
.unwrap_or(0);
1693+
if enq > 0 && std::env::var("TJ_DISABLE_CLASSIFY_SPAWN").is_err() {
1694+
let _ = spawn_classify_worker(&backend);
1695+
}
1696+
}
1697+
}
1698+
return Ok(());
1699+
}
1700+
16401701
// Drain any pending entries first (Task 10 fills the real-classifier branch).
16411702
drain_pending(
16421703
&events_path,
@@ -2814,16 +2875,27 @@ fn persist_pending_v2(
28142875
Ok(path)
28152876
}
28162877

2817-
/// PreCompact catch-up: parse the transcript JSONL and enqueue text
2818-
/// chunks newer than `last_event_ts` as pending v2 entries. The
2819-
/// classify-worker picks them up afterwards. Returns the number of
2820-
/// chunks queued. Errors are absorbed — best-effort, never fatal.
2821-
fn precompact_enqueue_transcript_chunks(
2878+
/// Transcript catch-up: parse the JSONL session log and enqueue user
2879+
/// + assistant text entries newer than `last_event_ts` as pending v2
2880+
/// chunks. The classify-worker picks them up afterwards. Returns the
2881+
/// number of chunks queued. Errors are absorbed — best-effort, never
2882+
/// fatal. Used by both PreCompact (before compaction) and Stop (end
2883+
/// of session) hooks to recover events the synchronous PostToolUse
2884+
/// hook didn't see — internal classifier `claude -p` calls, MCP
2885+
/// responses with thinking-only assistant turns, or the final
2886+
/// assistant message before a session ends.
2887+
///
2888+
/// `assistant_chunk_kind` tags assistant-side entries so the source
2889+
/// hook is visible in the pending queue (e.g. "PreCompactChunk"
2890+
/// vs "StopChunk"). User entries always tag as "UserPromptSubmit"
2891+
/// to trigger `process_pending_entry`'s auto-open behavior.
2892+
fn enqueue_transcript_chunks_since_last_event(
28222893
transcript_path: &std::path::Path,
28232894
events_path: &std::path::Path,
28242895
project_hash: &str,
28252896
backend: &str,
28262897
last_event_ts: Option<&str>,
2898+
assistant_chunk_kind: &str,
28272899
) -> anyhow::Result<usize> {
28282900
use tj_core::session::parser::{
28292901
extract_assistant_texts, extract_user_text, parse_session, SessionEntry,
@@ -2844,7 +2916,7 @@ fn precompact_enqueue_transcript_chunks(
28442916
if texts.is_empty() {
28452917
continue;
28462918
}
2847-
(a.timestamp.clone(), texts.join("\n"), "PreCompactChunk")
2919+
(a.timestamp.clone(), texts.join("\n"), assistant_chunk_kind)
28482920
}
28492921
_ => continue,
28502922
};

crates/tj-cli/tests/cli.rs

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2557,6 +2557,123 @@ fn precompact_skips_transcript_entries_older_than_last_event() {
25572557
);
25582558
}
25592559

2560+
#[test]
2561+
fn stop_ingests_transcript_tail_into_pending_v2() {
2562+
// v0.9.3: Stop hook does transcript catch-up (like PreCompact)
2563+
// instead of injecting hardcoded "Session ended" noise.
2564+
let dir = assert_fs::TempDir::new().unwrap();
2565+
let workdir = dir.path().join("proj");
2566+
std::fs::create_dir_all(&workdir).unwrap();
2567+
2568+
let _task_id = String::from_utf8(
2569+
Command::cargo_bin("task-journal")
2570+
.unwrap()
2571+
.env("XDG_DATA_HOME", dir.path())
2572+
.current_dir(&workdir)
2573+
.args(["create", "End-of-session catch-up"])
2574+
.assert()
2575+
.success()
2576+
.get_output()
2577+
.stdout
2578+
.clone(),
2579+
)
2580+
.unwrap()
2581+
.trim()
2582+
.to_string();
2583+
2584+
let transcript = workdir.join("session.jsonl");
2585+
let line_user = r#"{"type":"user","uuid":"u1","timestamp":"2099-01-01T00:00:00.000Z","sessionId":"s1","message":{"content":"the refund flow needs idempotency keys per payment provider"}}"#;
2586+
let line_assistant = r#"{"type":"assistant","uuid":"a1","timestamp":"2099-01-01T00:00:05.000Z","sessionId":"s1","message":{"content":[{"type":"text","text":"Confirmed: dlocal returns 200 OK for duplicate calls when idempotency-key header is set."}]}}"#;
2587+
std::fs::write(&transcript, format!("{line_user}\n{line_assistant}\n")).unwrap();
2588+
2589+
let stdin_payload = serde_json::json!({
2590+
"hook_event_name": "Stop",
2591+
"transcript_path": transcript.to_str().unwrap(),
2592+
})
2593+
.to_string();
2594+
2595+
Command::cargo_bin("task-journal")
2596+
.unwrap()
2597+
.env("XDG_DATA_HOME", dir.path())
2598+
.env("TJ_DISABLE_CLASSIFY_SPAWN", "1")
2599+
.current_dir(&workdir)
2600+
.args(["ingest-hook", "--backend", "hybrid"])
2601+
.write_stdin(stdin_payload)
2602+
.assert()
2603+
.success();
2604+
2605+
let pending_dir = dir.path().join("task-journal").join("pending");
2606+
let queued: Vec<_> = std::fs::read_dir(&pending_dir)
2607+
.expect("pending dir must exist after Stop catch-up")
2608+
.filter_map(|e| e.ok())
2609+
.collect();
2610+
assert_eq!(
2611+
queued.len(),
2612+
2,
2613+
"expected 2 pending v2 chunks (user + assistant), got {}",
2614+
queued.len()
2615+
);
2616+
2617+
let mut saw_user = false;
2618+
let mut saw_assistant = false;
2619+
for entry in &queued {
2620+
let body = std::fs::read_to_string(entry.path()).unwrap();
2621+
let v: serde_json::Value = serde_json::from_str(&body).unwrap();
2622+
assert_eq!(v["schema"], "v2");
2623+
let text = v["text"].as_str().unwrap_or("");
2624+
if text.contains("idempotency keys per payment provider") {
2625+
saw_user = true;
2626+
assert_eq!(v["kind"], "UserPromptSubmit");
2627+
}
2628+
if text.contains("dlocal returns 200 OK") {
2629+
saw_assistant = true;
2630+
// Distinct kind from PreCompactChunk — lets ops grep which hook queued it.
2631+
assert_eq!(v["kind"], "StopChunk");
2632+
}
2633+
}
2634+
assert!(
2635+
saw_user && saw_assistant,
2636+
"missing one of the chunks: user={saw_user} assistant={saw_assistant}"
2637+
);
2638+
}
2639+
2640+
#[test]
2641+
fn stop_without_transcript_path_is_silent_noop() {
2642+
// Belt-and-braces: when CC's Stop payload omits transcript_path
2643+
// (or hook is invoked manually with no stdin), we must not crash
2644+
// and must not litter pending/ with placeholder entries — the
2645+
// pre-v0.9.3 "Session ended" text noise we deliberately removed.
2646+
let dir = assert_fs::TempDir::new().unwrap();
2647+
let workdir = dir.path().join("proj");
2648+
std::fs::create_dir_all(&workdir).unwrap();
2649+
Command::cargo_bin("task-journal")
2650+
.unwrap()
2651+
.env("XDG_DATA_HOME", dir.path())
2652+
.current_dir(&workdir)
2653+
.args(["create", "noop check"])
2654+
.assert()
2655+
.success();
2656+
2657+
let stdin_payload = serde_json::json!({"hook_event_name": "Stop"}).to_string();
2658+
Command::cargo_bin("task-journal")
2659+
.unwrap()
2660+
.env("XDG_DATA_HOME", dir.path())
2661+
.current_dir(&workdir)
2662+
.args(["ingest-hook", "--backend", "hybrid"])
2663+
.write_stdin(stdin_payload)
2664+
.assert()
2665+
.success();
2666+
2667+
let pending_dir = dir.path().join("task-journal").join("pending");
2668+
let queued_count = std::fs::read_dir(&pending_dir)
2669+
.map(|it| it.count())
2670+
.unwrap_or(0);
2671+
assert_eq!(
2672+
queued_count, 0,
2673+
"Stop without transcript_path must NOT queue any pending entry"
2674+
);
2675+
}
2676+
25602677
#[test]
25612678
fn rewind_prompt_appends_correction_event() {
25622679
let dir = assert_fs::TempDir::new().unwrap();

crates/tj-mcp/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ name = "task-journal-mcp"
1616
path = "src/main.rs"
1717

1818
[dependencies]
19-
tj-core = { package = "task-journal-core", version = "0.9.2", path = "../tj-core" }
19+
tj-core = { package = "task-journal-core", version = "0.9.3", path = "../tj-core" }
2020
anyhow = { workspace = true }
2121
tokio = { workspace = true }
2222
tracing = { workspace = true }

plugin/.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.9.2",
3+
"version": "0.9.3",
44
"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.",
55
"author": {
66
"name": "Mher Shahinyan"

plugin/hooks/hooks.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,10 +24,11 @@
2424
],
2525
"Stop": [
2626
{
27+
"matcher": "",
2728
"hooks": [
2829
{
2930
"type": "command",
30-
"command": "task-journal ingest-hook --kind=Stop --text=\"Session ended\" 2>/dev/null || true"
31+
"command": "task-journal ingest-hook 2>/dev/null || true"
3132
}
3233
]
3334
}

plugin/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "task-journal",
3-
"version": "0.9.2",
3+
"version": "0.9.3",
44
"description": "Append-only journal of AI-coding task reasoning chains. Captures hypotheses, decisions, rejections, evidence — renders compact resume packs so an agent can pick up a 2-week-old task with full context.",
55
"author": {
66
"name": "Mher Shahinyan",

0 commit comments

Comments
 (0)