@@ -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+
16341730fn 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+ }
0 commit comments