Skip to content

Commit 23692e2

Browse files
authored
feat: add SessionStart Claude Code hook that injects wake-up pack (#86)
Adds a `SessionStart` hook that auto-injects a wake-up pack of critical memories at the start of every Claude Code session, closing the loop on the `icm wake-up` feature shipped in v0.10.15. The hook detects the current project from the session's `cwd`, builds a 200-token pack via `icm_core::build_wake_up`, and prints it to stdout where Claude Code picks it up as additional session context. - icm-cli: - new `HookCommands::Start { max_tokens }` subcommand → `icm hook start` - new `cmd_hook_start` / `build_hook_start_pack` / `project_from_path` - `cmd_init` now installs a SessionStart entry alongside the 4 existing hooks (idempotent — verified by smoke test) - `ICM_HOOK_DEBUG=1` env var enables stderr diagnostics when the hook decides to suppress output - 10 unit tests covering project parsing, cwd scoping, empty store, malformed stdin, missing cwd field, empty cwd string, token budget, placeholder suppression, and a regression guard on the empty-pack header constant - icm-core: - new `EMPTY_PACK_HEADER` exported constant so the hook can detect the empty case without substring-matching the rendered body (fixes a fragile coupling flagged by code review) - `render()` now uses the constant instead of a hardcoded header string The hook is read-only (no `update_access` side effects) and respects the existing segment-aware project filter. Trust boundary documented in the `cmd_hook_start` doc-comment: pack content is user-authored so no prompt injection escaping is needed beyond the newline sanitization already in `wake_up::sanitize_summary`. Total: 249 workspace tests green. Clippy clean. Independent code review flagged 2 should-fix items (fragile marker, silent suppression) — both addressed before this commit.
1 parent 06ad3eb commit 23692e2

File tree

4 files changed

+289
-4
lines changed

4 files changed

+289
-4
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

crates/icm-cli/src/main.rs

Lines changed: 277 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -408,6 +408,12 @@ enum HookCommands {
408408
Compact,
409409
/// UserPromptSubmit hook: inject recalled context at the start of each prompt
410410
Prompt,
411+
/// SessionStart hook: inject a wake-up pack of critical facts into the session
412+
Start {
413+
/// Approximate token budget for the wake-up pack
414+
#[arg(long, default_value = "200")]
415+
max_tokens: usize,
416+
},
411417
}
412418

413419
#[derive(Subcommand)]
@@ -1019,6 +1025,7 @@ fn main() -> Result<()> {
10191025
}
10201026
HookCommands::Compact => cmd_hook_compact(&store),
10211027
HookCommands::Prompt => cmd_hook_prompt(&store),
1028+
HookCommands::Start { max_tokens } => cmd_hook_start(&store, max_tokens),
10221029
},
10231030
#[cfg(feature = "tui")]
10241031
Commands::Dashboard => {
@@ -1631,6 +1638,95 @@ fn cmd_hook_prompt(store: &SqliteStore) -> Result<()> {
16311638
Ok(())
16321639
}
16331640

1641+
/// SessionStart hook (Layer 0): inject a wake-up pack of critical memories at
1642+
/// session start. Reads `cwd` from the Claude Code hook JSON to auto-detect
1643+
/// the project, builds the pack via `build_wake_up`, and writes it to stdout.
1644+
///
1645+
/// Claude Code injects stdout from SessionStart hooks as additional system
1646+
/// context for the session. If the pack is empty (no critical memories), we
1647+
/// write nothing so the session starts unchanged.
1648+
///
1649+
/// **Trust boundary**: the pack content is drawn from the user's own ICM
1650+
/// store and auto-injected into the session without user confirmation.
1651+
/// Summaries are sanitized (newlines flattened in `wake_up::sanitize_summary`)
1652+
/// but backticks / code fences / prompt-injection markers are NOT escaped.
1653+
/// This is acceptable because ICM memories are user-authored — the user is
1654+
/// the only party who can influence the injected content.
1655+
///
1656+
/// Set `ICM_HOOK_DEBUG=1` in the environment to get stderr diagnostics when
1657+
/// the hook decides to suppress output (empty store, no matching memories).
1658+
fn cmd_hook_start(store: &SqliteStore, max_tokens: usize) -> Result<()> {
1659+
use std::io::Read;
1660+
let mut input = String::new();
1661+
std::io::stdin().read_to_string(&mut input)?;
1662+
1663+
let pack = build_hook_start_pack(store, &input, max_tokens)?;
1664+
if pack.is_empty() {
1665+
if std::env::var("ICM_HOOK_DEBUG").is_ok() {
1666+
eprintln!("[icm hook start] suppressed (empty store or no matching memories)");
1667+
}
1668+
return Ok(());
1669+
}
1670+
print!("{pack}");
1671+
Ok(())
1672+
}
1673+
1674+
/// Build the SessionStart wake-up pack from hook stdin + store. Pure helper
1675+
/// for unit testing: no I/O beyond the store query.
1676+
///
1677+
/// Returns the pack as a String, or an empty string if there is nothing
1678+
/// meaningful to inject (empty store, or placeholder output).
1679+
fn build_hook_start_pack(
1680+
store: &SqliteStore,
1681+
stdin_json: &str,
1682+
max_tokens: usize,
1683+
) -> Result<String> {
1684+
// Tolerate missing/malformed stdin — fall back to PWD-based detection.
1685+
let cwd: Option<String> = serde_json::from_str::<Value>(stdin_json)
1686+
.ok()
1687+
.and_then(|v| v.get("cwd").and_then(|c| c.as_str()).map(String::from));
1688+
1689+
let project_name = match cwd.as_deref() {
1690+
Some(path) if !path.is_empty() => project_from_path(path),
1691+
_ => {
1692+
let detected = detect_project();
1693+
if detected.is_empty() || detected == "unknown" {
1694+
None
1695+
} else {
1696+
Some(detected)
1697+
}
1698+
}
1699+
};
1700+
1701+
let opts = icm_core::WakeUpOptions {
1702+
project: project_name.as_deref(),
1703+
max_tokens,
1704+
format: icm_core::WakeUpFormat::Markdown,
1705+
include_preferences: true,
1706+
};
1707+
1708+
let pack = icm_core::build_wake_up(store, &opts)?;
1709+
1710+
// If the store is empty, skip injecting the placeholder output into the
1711+
// session — let the user start clean. We detect the empty case via the
1712+
// exported header constant, not substring matching the body, to stay
1713+
// decoupled from the exact wording in `icm_core::wake_up::render()`.
1714+
if pack.trim().is_empty() || pack.starts_with(icm_core::EMPTY_PACK_HEADER) {
1715+
return Ok(String::new());
1716+
}
1717+
1718+
Ok(pack)
1719+
}
1720+
1721+
/// Extract a project name from a filesystem path (basename), treating empty
1722+
/// or root paths as "no project".
1723+
fn project_from_path(path: &str) -> Option<String> {
1724+
let p = std::path::Path::new(path);
1725+
p.file_name()
1726+
.map(|n| n.to_string_lossy().to_string())
1727+
.filter(|s| !s.is_empty() && s != "/")
1728+
}
1729+
16341730
fn cmd_topics(store: &SqliteStore) -> Result<()> {
16351731
let topics = store.list_topics()?;
16361732
if topics.is_empty() {
@@ -2033,6 +2129,17 @@ Do this BEFORE responding to the user. Not optional.
20332129
)?;
20342130
println!("[hook] Claude Code UserPromptSubmit (auto-recall): {prompt_status}");
20352131

2132+
// SessionStart hook: `icm hook start` (inject wake-up pack of critical facts)
2133+
let start_cmd = format!("{} hook start", icm_bin_str);
2134+
let start_status = inject_claude_hook(
2135+
&claude_settings_path,
2136+
"SessionStart",
2137+
&start_cmd,
2138+
None,
2139+
&["icm hook start", "icm hook", "icm-post-tool"],
2140+
)?;
2141+
println!("[hook] Claude Code SessionStart (wake-up pack): {start_status}");
2142+
20362143
// OpenCode plugin: install TS plugin using native @opencode-ai/plugin SDK
20372144
let opencode_plugins_dir = PathBuf::from(&home).join(".config/opencode/plugins");
20382145
let opencode_plugin_path = opencode_plugins_dir.join("icm.ts");
@@ -4424,3 +4531,173 @@ fn cmd_cloud(command: CloudCommands, store: &SqliteStore) -> Result<()> {
44244531
}
44254532
}
44264533
}
4534+
4535+
#[cfg(test)]
4536+
mod hook_start_tests {
4537+
use super::*;
4538+
use icm_core::Importance;
4539+
4540+
fn seed_store() -> SqliteStore {
4541+
let store = SqliteStore::in_memory().unwrap();
4542+
store
4543+
.store(Memory::new(
4544+
"decisions-icm".into(),
4545+
"Use SQLite with FTS5 and sqlite-vec".into(),
4546+
Importance::Critical,
4547+
))
4548+
.unwrap();
4549+
store
4550+
.store(Memory::new(
4551+
"decisions-other".into(),
4552+
"OTHER project uses Postgres".into(),
4553+
Importance::Critical,
4554+
))
4555+
.unwrap();
4556+
store
4557+
.store(Memory::new(
4558+
"preferences".into(),
4559+
"User prefers French responses".into(),
4560+
Importance::High,
4561+
))
4562+
.unwrap();
4563+
store
4564+
.store(Memory::new(
4565+
"low-noise".into(),
4566+
"Irrelevant low-importance trivia".into(),
4567+
Importance::Low,
4568+
))
4569+
.unwrap();
4570+
store
4571+
}
4572+
4573+
#[test]
4574+
fn project_from_path_extracts_basename() {
4575+
assert_eq!(
4576+
project_from_path("/Users/patrick/dev/rtk-ai/icm"),
4577+
Some("icm".into())
4578+
);
4579+
assert_eq!(
4580+
project_from_path("/tmp/my-project"),
4581+
Some("my-project".into())
4582+
);
4583+
assert_eq!(project_from_path(""), None);
4584+
}
4585+
4586+
#[test]
4587+
fn hook_start_pack_scopes_to_cwd_project() {
4588+
let store = seed_store();
4589+
let stdin_json = r#"{"cwd":"/Users/patrick/dev/rtk-ai/icm","session_id":"abc"}"#;
4590+
let pack = build_hook_start_pack(&store, stdin_json, 200).unwrap();
4591+
assert!(pack.contains("SQLite"), "icm decision missing: {pack}");
4592+
assert!(pack.contains("French"), "preference missing: {pack}");
4593+
assert!(
4594+
!pack.contains("Postgres"),
4595+
"other project leaked into icm session: {pack}"
4596+
);
4597+
assert!(pack.contains("project: icm"));
4598+
}
4599+
4600+
#[test]
4601+
fn hook_start_pack_empty_on_empty_store() {
4602+
let store = SqliteStore::in_memory().unwrap();
4603+
let stdin_json = r#"{"cwd":"/Users/patrick/dev/rtk-ai/icm"}"#;
4604+
let pack = build_hook_start_pack(&store, stdin_json, 200).unwrap();
4605+
assert!(
4606+
pack.is_empty(),
4607+
"expected empty pack for empty store, got: {pack}"
4608+
);
4609+
}
4610+
4611+
#[test]
4612+
fn hook_start_pack_tolerates_malformed_stdin() {
4613+
let store = seed_store();
4614+
// Not JSON at all — should fall back to project auto-detection or None
4615+
let pack = build_hook_start_pack(&store, "garbage not json", 200).unwrap();
4616+
// Either it auto-detected nothing (then all memories pass) or auto-detected a
4617+
// real repo name — either way, must not panic and must produce valid output.
4618+
assert!(!pack.is_empty());
4619+
assert!(pack.starts_with("# ICM Wake-up"));
4620+
}
4621+
4622+
#[test]
4623+
fn hook_start_pack_tolerates_missing_cwd_field() {
4624+
let store = seed_store();
4625+
let stdin_json = r#"{"session_id":"abc","transcript_path":"/tmp/t.jsonl"}"#;
4626+
let pack = build_hook_start_pack(&store, stdin_json, 200).unwrap();
4627+
// No cwd → falls back to detect_project() which will use current test
4628+
// process PWD. We don't assert on the specific project but we do verify
4629+
// the call doesn't fail and we get some output.
4630+
assert!(pack.starts_with("# ICM Wake-up"));
4631+
}
4632+
4633+
#[test]
4634+
fn hook_start_pack_respects_token_budget() {
4635+
let store = SqliteStore::in_memory().unwrap();
4636+
for i in 0..50 {
4637+
store
4638+
.store(Memory::new(
4639+
"decisions-icm".into(),
4640+
format!("Critical decision {i} with a reasonably long description text here"),
4641+
Importance::Critical,
4642+
))
4643+
.unwrap();
4644+
}
4645+
let stdin_json = r#"{"cwd":"/path/icm"}"#;
4646+
4647+
let small = build_hook_start_pack(&store, stdin_json, 50).unwrap();
4648+
let large = build_hook_start_pack(&store, stdin_json, 500).unwrap();
4649+
4650+
assert!(small.len() < large.len(), "budget should shrink output");
4651+
assert!(
4652+
small.len() < 500,
4653+
"50 tok budget should stay under 500 chars"
4654+
);
4655+
}
4656+
4657+
#[test]
4658+
fn hook_start_pack_skips_placeholder_output() {
4659+
let store = SqliteStore::in_memory().unwrap();
4660+
// Only low-importance noise — wake-up would return the "(no critical
4661+
// memories yet ...)" placeholder, which cmd_hook_start should suppress.
4662+
store
4663+
.store(Memory::new(
4664+
"noise".into(),
4665+
"nothing important".into(),
4666+
Importance::Low,
4667+
))
4668+
.unwrap();
4669+
let pack = build_hook_start_pack(&store, r#"{"cwd":"/p/x"}"#, 200).unwrap();
4670+
assert!(
4671+
pack.is_empty(),
4672+
"placeholder output should be suppressed to keep session clean: {pack}"
4673+
);
4674+
}
4675+
4676+
#[test]
4677+
fn hook_start_placeholder_detection_uses_exported_header() {
4678+
// Regression guard: build an empty wake-up pack via icm_core and
4679+
// assert it starts with the header that cmd_hook_start checks. If
4680+
// someone reformats the placeholder in wake_up.rs, this test fails
4681+
// and forces an update rather than silently breaking suppression.
4682+
let empty_pack =
4683+
icm_core::build_wake_up_from_memories(Vec::new(), &icm_core::WakeUpOptions::default());
4684+
assert!(
4685+
empty_pack.starts_with(icm_core::EMPTY_PACK_HEADER),
4686+
"empty wake-up pack no longer starts with EMPTY_PACK_HEADER — \
4687+
update the constant or adjust suppression logic: {empty_pack}"
4688+
);
4689+
}
4690+
4691+
#[test]
4692+
fn hook_start_pack_with_empty_cwd_string_falls_back() {
4693+
let store = seed_store();
4694+
// Edge case: cwd present but empty string — should fall through to
4695+
// detect_project() rather than matching "" against topics.
4696+
let stdin_json = r#"{"cwd":""}"#;
4697+
let pack = build_hook_start_pack(&store, stdin_json, 200).unwrap();
4698+
// We don't assert on which project was picked; we just require the
4699+
// call does not panic and returns a valid, non-empty pack.
4700+
assert!(!pack.is_empty());
4701+
assert!(pack.starts_with("# ICM Wake-up"));
4702+
}
4703+
}

crates/icm-core/src/lib.rs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,9 @@ pub use memory::{
2626
Importance, Memory, MemorySource, PatternCluster, Scope, StoreStats, TopicHealth,
2727
};
2828
pub use store::MemoryStore;
29-
pub use wake_up::{build_wake_up, build_wake_up_from_memories, WakeUpFormat, WakeUpOptions};
29+
pub use wake_up::{
30+
build_wake_up, build_wake_up_from_memories, WakeUpFormat, WakeUpOptions, EMPTY_PACK_HEADER,
31+
};
3032

3133
pub use learn::{learn_project, LearnResult};
3234

crates/icm-core/src/wake_up.rs

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -280,10 +280,16 @@ fn sanitize_summary(summary: &str) -> String {
280280
out
281281
}
282282

283+
/// Placeholder header written by `build_wake_up*` when no critical/high
284+
/// memories match the options. Exposed as a constant so callers (notably
285+
/// the SessionStart hook) can detect the empty case without coupling to
286+
/// the exact wording of the body.
287+
pub const EMPTY_PACK_HEADER: &str = "# ICM Wake-up (empty)";
288+
283289
fn render(selected: &[ScoredMemory], opts: &WakeUpOptions<'_>) -> String {
284290
if selected.is_empty() {
285-
return String::from(
286-
"# ICM Wake-up\n\n(no critical memories yet — use `icm store` to seed)\n",
291+
return format!(
292+
"{EMPTY_PACK_HEADER}\n\n(no critical memories yet — use `icm store` to seed)\n"
287293
);
288294
}
289295

0 commit comments

Comments
 (0)