Skip to content

Commit 755b883

Browse files
Shahinyanmclaude
andcommitted
feat(hook): SessionStart auto-injects resume-pack of open tasks
Without this, the journal could record reasoning chains but couldn't *surface* them — every new Claude Code session started cold and the user had to remember to call task_pack manually. That defeats the auto-memory promise of the project. Behavior: - `task-journal ingest-hook --kind=SessionStart` now short-circuits before the classifier path. It opens the project SQLite, ingests any new JSONL events, queries up to 3 most-recently-active OPEN tasks, renders each in compact mode via tj_core::pack::assemble, and writes a JSON envelope to stdout: { "hookSpecificOutput": { "hookEventName": "SessionStart", "additionalContext": "<concatenated packs>" } } Claude Code reads that envelope and folds additionalContext into the system prompt. - No events file → empty stdout (no ghost SQLite files for projects that never used the journal). - No open tasks → empty stdout (don't pollute system prompt). - `install-hooks` now wires SessionStart alongside UserPromptSubmit / PostToolUse / Stop, so the auto-injection is on by default. Two new tests cover the happy path (envelope shape + content) and the no-tasks silent path. install-hooks test asserts SessionStart is present in the generated settings.json. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1 parent d043251 commit 755b883

2 files changed

Lines changed: 140 additions & 0 deletions

File tree

crates/tj-cli/src/main.rs

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -999,6 +999,11 @@ fn main() -> Result<()> {
999999
"UserPromptSubmit": [{ "matcher": "", "hooks": [{ "type": "command", "command": cmd }] }],
10001000
"PostToolUse": [{ "matcher": "", "hooks": [{ "type": "command", "command": cmd }] }],
10011001
"Stop": [{ "matcher": "", "hooks": [{ "type": "command", "command": cmd }] }],
1002+
// SessionStart drives the auto resume-pack injection:
1003+
// ingest-hook short-circuits on this kind, queries open
1004+
// tasks for the current project, and emits the
1005+
// additionalContext envelope Claude Code expects.
1006+
"SessionStart": [{ "matcher": "", "hooks": [{ "type": "command", "command": cmd }] }],
10021007
});
10031008
hooks_obj.insert("hooks".into(), entries);
10041009

@@ -1104,6 +1109,47 @@ fn main() -> Result<()> {
11041109
let events_path = tj_core::paths::events_dir()?.join(format!("{project_hash}.jsonl"));
11051110
std::fs::create_dir_all(events_path.parent().unwrap())?;
11061111

1112+
// SessionStart: emit a JSON envelope with compact resume-packs of
1113+
// open tasks so Claude Code injects them into its system context
1114+
// automatically. This is the load-bearing UX for "the journal
1115+
// remembers" — without it, users would have to call task_pack
1116+
// manually each session. Empty stdout when no open tasks → no
1117+
// injection, keeps system prompt clean for fresh projects.
1118+
if kind == "SessionStart" {
1119+
// Skip early on a clean machine: nothing to surface, and we
1120+
// don't want SessionStart to spawn empty SQLite files in
1121+
// every project Claude Code is opened in.
1122+
if !events_path.exists() {
1123+
return Ok(());
1124+
}
1125+
let state_path =
1126+
tj_core::paths::state_dir()?.join(format!("{project_hash}.sqlite"));
1127+
let conn = tj_core::db::open(&state_path)?;
1128+
tj_core::db::ingest_new_events(&conn, &events_path, &project_hash)?;
1129+
let recent = recent_task_contexts(&conn, 3)?;
1130+
if recent.is_empty() {
1131+
return Ok(());
1132+
}
1133+
let mut bundle = String::new();
1134+
for tc in &recent {
1135+
let pack = tj_core::pack::assemble(
1136+
&conn,
1137+
&tc.task_id,
1138+
tj_core::pack::PackMode::Compact,
1139+
)?;
1140+
bundle.push_str(&pack.text);
1141+
bundle.push_str("\n\n");
1142+
}
1143+
let envelope = serde_json::json!({
1144+
"hookSpecificOutput": {
1145+
"hookEventName": "SessionStart",
1146+
"additionalContext": bundle.trim_end(),
1147+
}
1148+
});
1149+
println!("{}", serde_json::to_string(&envelope)?);
1150+
return Ok(());
1151+
}
1152+
11071153
// Drain any pending entries first (Task 10 fills the real-classifier branch).
11081154
drain_pending(
11091155
&events_path,

crates/tj-cli/tests/cli.rs

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,10 @@ fn install_hooks_writes_to_settings_json() {
800800
assert!(content.contains("UserPromptSubmit"));
801801
assert!(content.contains("PostToolUse"));
802802
assert!(content.contains("task-journal ingest-hook"));
803+
assert!(
804+
content.contains("SessionStart"),
805+
"install-hooks must wire SessionStart so resume-pack injection works"
806+
);
803807
}
804808

805809
#[test]
@@ -1078,6 +1082,96 @@ fn ingest_hook_writes_telemetry_record() {
10781082
);
10791083
}
10801084

1085+
#[test]
1086+
fn ingest_hook_session_start_emits_resume_pack_json() {
1087+
let dir = assert_fs::TempDir::new().unwrap();
1088+
let task_id = String::from_utf8(
1089+
Command::cargo_bin("task-journal")
1090+
.unwrap()
1091+
.env("XDG_DATA_HOME", dir.path())
1092+
.args(["create", "Wire SessionStart pack"])
1093+
.assert()
1094+
.success()
1095+
.get_output()
1096+
.stdout
1097+
.clone(),
1098+
)
1099+
.unwrap()
1100+
.trim()
1101+
.to_string();
1102+
1103+
Command::cargo_bin("task-journal")
1104+
.unwrap()
1105+
.env("XDG_DATA_HOME", dir.path())
1106+
.args([
1107+
"event",
1108+
&task_id,
1109+
"--type",
1110+
"decision",
1111+
"--text",
1112+
"Adopt Rust for the journal.",
1113+
])
1114+
.assert()
1115+
.success();
1116+
1117+
let out = Command::cargo_bin("task-journal")
1118+
.unwrap()
1119+
.env("XDG_DATA_HOME", dir.path())
1120+
.args(["ingest-hook", "--kind", "SessionStart", "--text", ""])
1121+
.assert()
1122+
.success()
1123+
.get_output()
1124+
.stdout
1125+
.clone();
1126+
let body = String::from_utf8(out).unwrap();
1127+
1128+
let v: serde_json::Value = serde_json::from_str(body.trim()).unwrap_or_else(|e| {
1129+
panic!("SessionStart hook stdout must be JSON; got: {body:?}; err: {e}")
1130+
});
1131+
let hso = v
1132+
.get("hookSpecificOutput")
1133+
.expect("hookSpecificOutput key missing");
1134+
assert_eq!(
1135+
hso.get("hookEventName").and_then(|s| s.as_str()),
1136+
Some("SessionStart"),
1137+
"wrong hookEventName: {body}"
1138+
);
1139+
let ctx = hso
1140+
.get("additionalContext")
1141+
.and_then(|s| s.as_str())
1142+
.expect("additionalContext key missing");
1143+
assert!(
1144+
ctx.contains("Wire SessionStart pack"),
1145+
"additionalContext must include task title: {ctx}"
1146+
);
1147+
assert!(
1148+
ctx.contains("Adopt Rust"),
1149+
"additionalContext must include event text: {ctx}"
1150+
);
1151+
}
1152+
1153+
#[test]
1154+
fn ingest_hook_session_start_with_no_open_tasks_emits_no_context() {
1155+
let dir = assert_fs::TempDir::new().unwrap();
1156+
let out = Command::cargo_bin("task-journal")
1157+
.unwrap()
1158+
.env("XDG_DATA_HOME", dir.path())
1159+
.args(["ingest-hook", "--kind", "SessionStart", "--text", ""])
1160+
.assert()
1161+
.success()
1162+
.get_output()
1163+
.stdout
1164+
.clone();
1165+
let body = String::from_utf8(out).unwrap();
1166+
// Empty stdout is the documented signal to Claude Code that no
1167+
// additionalContext should be injected — we don't want to pollute
1168+
// the system prompt with an empty pack on fresh projects.
1169+
assert!(
1170+
body.trim().is_empty(),
1171+
"SessionStart with no open tasks must emit nothing, got: {body:?}"
1172+
);
1173+
}
1174+
10811175
#[test]
10821176
fn ingest_hook_with_mock_writes_classified_event() {
10831177
let dir = assert_fs::TempDir::new().unwrap();

0 commit comments

Comments
 (0)