spool-memory 0.2.3

Local-first developer memory system — persistent, structured knowledge for AI coding tools
Documentation
//! Hook runtime — `spool hook <subcommand>` entry points.
//!
//! ## Why hooks?
//! spool is normally invoked via MCP tools, which require the user to
//! explicitly call them. Hooks let the AI client (Claude Code first,
//! later Codex/Cursor) trigger spool automatically at well-defined
//! lifecycle events: session start, user prompt, post-tool-use, stop,
//! pre-compact. This is what turns spool from a "passive MCP server"
//! into a "proactive memory runtime".
//!
//! ## Hook contract
//!
//! Every hook MUST:
//! 1. **Be silent on failure** (D7). Any error short-circuits to
//!    [`run_silent`] which logs the error to stderr and exits with
//!    status 0. We never propagate errors back to Claude Code because
//!    a flaky hook must not block the user's prompt.
//! 2. **Return in <500ms p95.** Network calls are forbidden inside a
//!    hook body; only local file I/O and ledger reads are allowed.
//! 3. **Be idempotent.** A hook may be invoked twice for the same
//!    event (e.g. Claude Code retries on transient errors). Writes
//!    must tolerate repeats.
//! 4. **Stay independent.** Hooks talk to disk; they never assume any
//!    other spool process is running.
//!
//! ## Module layout
//! - [`session_start`] — emit wakeup packet to stdout (R2 core delivery)
//! - [`user_prompt`] — detect cwd/task switch, optional lightweight recall
//! - [`post_tool_use`] — append a signal envelope to the distill queue
//! - [`stop`] — placeholder until R3 transcript heuristics
//! - [`pre_compact`] — placeholder until R3 self-tag preservation
//!
//! Each submodule exposes a single `run(args) -> anyhow::Result<()>`
//! function that the CLI dispatches to via [`run_silent`].

pub mod post_tool_use;
pub mod pre_compact;
pub mod session_start;
pub mod stop;
pub mod user_prompt;

use std::path::{Path, PathBuf};

/// Execute a hook body and swallow any error so Claude Code never sees
/// a non-zero exit (D7).
///
/// Errors are written to stderr (visible in `~/.claude/logs/hooks.log`)
/// for debugging without affecting the parent process.
pub fn run_silent<F>(hook_name: &str, body: F)
where
    F: FnOnce() -> anyhow::Result<()>,
{
    if let Err(err) = body() {
        eprintln!("[spool hook {}] suppressed error: {:#}", hook_name, err);
    }
}

/// Resolve the per-cwd `.spool/` directory used to buffer hook
/// signals (distill queue, last-prompt timestamp, …).
///
/// The directory is created on demand. We deliberately scope it under
/// the project repo (`<cwd>/.spool/`) rather than under `$HOME` so that
/// (a) signals stay attached to the project the user is currently
/// working on, and (b) `git clean` / project relocation naturally
/// resets the queue.
pub fn project_runtime_dir(cwd: &Path) -> anyhow::Result<PathBuf> {
    let dir = cwd.join(".spool");
    if !dir.exists() {
        std::fs::create_dir_all(&dir).map_err(|e| {
            anyhow::anyhow!("failed to create runtime dir {}: {}", dir.display(), e)
        })?;
    }
    Ok(dir)
}

/// Detect a sibling Trellis installation by probing for
/// `<cwd>/.trellis/.developer` (Trellis' identity marker).
///
/// When present, spool falls back to a degraded SessionStart payload to
/// avoid double-injecting context that Trellis already shows the user.
pub fn trellis_present(cwd: &Path) -> bool {
    cwd.join(".trellis").join(".developer").exists()
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn run_silent_swallows_errors() {
        // Just confirm it doesn't panic. stderr capture is too brittle.
        run_silent("unit-test", || anyhow::bail!("expected"));
    }

    #[test]
    fn run_silent_runs_ok_branch() {
        let mut hit = false;
        run_silent("unit-test", || {
            hit = true;
            Ok(())
        });
        assert!(hit);
    }

    #[test]
    fn project_runtime_dir_creates_directory() {
        let temp = tempdir().unwrap();
        let dir = project_runtime_dir(temp.path()).unwrap();
        assert!(dir.exists());
        assert!(dir.ends_with(".spool"));
    }

    #[test]
    fn project_runtime_dir_is_idempotent() {
        let temp = tempdir().unwrap();
        let _ = project_runtime_dir(temp.path()).unwrap();
        let _ = project_runtime_dir(temp.path()).unwrap();
        assert!(temp.path().join(".spool").exists());
    }

    #[test]
    fn trellis_present_returns_true_when_marker_exists() {
        let temp = tempdir().unwrap();
        let trellis = temp.path().join(".trellis");
        std::fs::create_dir_all(&trellis).unwrap();
        std::fs::write(trellis.join(".developer"), "name=long").unwrap();
        assert!(trellis_present(temp.path()));
    }

    #[test]
    fn trellis_present_false_when_absent() {
        let temp = tempdir().unwrap();
        assert!(!trellis_present(temp.path()));
    }
}