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}