Skip to main content

spool/hook_runtime/
mod.rs

1//! Hook runtime — `spool hook <subcommand>` entry points.
2//!
3//! ## Why hooks?
4//! spool is normally invoked via MCP tools, which require the user to
5//! explicitly call them. Hooks let the AI client (Claude Code first,
6//! later Codex/Cursor) trigger spool automatically at well-defined
7//! lifecycle events: session start, user prompt, post-tool-use, stop,
8//! pre-compact. This is what turns spool from a "passive MCP server"
9//! into a "proactive memory runtime".
10//!
11//! ## Hook contract
12//!
13//! Every hook MUST:
14//! 1. **Be silent on failure** (D7). Any error short-circuits to
15//!    [`run_silent`] which logs the error to stderr and exits with
16//!    status 0. We never propagate errors back to Claude Code because
17//!    a flaky hook must not block the user's prompt.
18//! 2. **Return in <500ms p95.** Network calls are forbidden inside a
19//!    hook body; only local file I/O and ledger reads are allowed.
20//! 3. **Be idempotent.** A hook may be invoked twice for the same
21//!    event (e.g. Claude Code retries on transient errors). Writes
22//!    must tolerate repeats.
23//! 4. **Stay independent.** Hooks talk to disk; they never assume any
24//!    other spool process is running.
25//!
26//! ## Module layout
27//! - [`session_start`] — emit wakeup packet to stdout (R2 core delivery)
28//! - [`user_prompt`] — detect cwd/task switch, optional lightweight recall
29//! - [`post_tool_use`] — append a signal envelope to the distill queue
30//! - [`stop`] — placeholder until R3 transcript heuristics
31//! - [`pre_compact`] — placeholder until R3 self-tag preservation
32//!
33//! Each submodule exposes a single `run(args) -> anyhow::Result<()>`
34//! function that the CLI dispatches to via [`run_silent`].
35
36pub mod post_tool_use;
37pub mod pre_compact;
38pub mod session_start;
39pub mod stop;
40pub mod user_prompt;
41
42use std::path::{Path, PathBuf};
43
44/// Execute a hook body and swallow any error so Claude Code never sees
45/// a non-zero exit (D7).
46///
47/// Errors are written to stderr (visible in `~/.claude/logs/hooks.log`)
48/// for debugging without affecting the parent process.
49pub fn run_silent<F>(hook_name: &str, body: F)
50where
51    F: FnOnce() -> anyhow::Result<()>,
52{
53    if let Err(err) = body() {
54        eprintln!("[spool hook {}] suppressed error: {:#}", hook_name, err);
55    }
56}
57
58/// Resolve the per-cwd `.spool/` directory used to buffer hook
59/// signals (distill queue, last-prompt timestamp, …).
60///
61/// The directory is created on demand. We deliberately scope it under
62/// the project repo (`<cwd>/.spool/`) rather than under `$HOME` so that
63/// (a) signals stay attached to the project the user is currently
64/// working on, and (b) `git clean` / project relocation naturally
65/// resets the queue.
66pub fn project_runtime_dir(cwd: &Path) -> anyhow::Result<PathBuf> {
67    let dir = cwd.join(".spool");
68    if !dir.exists() {
69        std::fs::create_dir_all(&dir).map_err(|e| {
70            anyhow::anyhow!("failed to create runtime dir {}: {}", dir.display(), e)
71        })?;
72    }
73    Ok(dir)
74}
75
76/// Detect a sibling Trellis installation by probing for
77/// `<cwd>/.trellis/.developer` (Trellis' identity marker).
78///
79/// When present, spool falls back to a degraded SessionStart payload to
80/// avoid double-injecting context that Trellis already shows the user.
81pub fn trellis_present(cwd: &Path) -> bool {
82    cwd.join(".trellis").join(".developer").exists()
83}
84
85#[cfg(test)]
86mod tests {
87    use super::*;
88    use tempfile::tempdir;
89
90    #[test]
91    fn run_silent_swallows_errors() {
92        // Just confirm it doesn't panic. stderr capture is too brittle.
93        run_silent("unit-test", || anyhow::bail!("expected"));
94    }
95
96    #[test]
97    fn run_silent_runs_ok_branch() {
98        let mut hit = false;
99        run_silent("unit-test", || {
100            hit = true;
101            Ok(())
102        });
103        assert!(hit);
104    }
105
106    #[test]
107    fn project_runtime_dir_creates_directory() {
108        let temp = tempdir().unwrap();
109        let dir = project_runtime_dir(temp.path()).unwrap();
110        assert!(dir.exists());
111        assert!(dir.ends_with(".spool"));
112    }
113
114    #[test]
115    fn project_runtime_dir_is_idempotent() {
116        let temp = tempdir().unwrap();
117        let _ = project_runtime_dir(temp.path()).unwrap();
118        let _ = project_runtime_dir(temp.path()).unwrap();
119        assert!(temp.path().join(".spool").exists());
120    }
121
122    #[test]
123    fn trellis_present_returns_true_when_marker_exists() {
124        let temp = tempdir().unwrap();
125        let trellis = temp.path().join(".trellis");
126        std::fs::create_dir_all(&trellis).unwrap();
127        std::fs::write(trellis.join(".developer"), "name=long").unwrap();
128        assert!(trellis_present(temp.path()));
129    }
130
131    #[test]
132    fn trellis_present_false_when_absent() {
133        let temp = tempdir().unwrap();
134        assert!(!trellis_present(temp.path()));
135    }
136}