Skip to main content

spool/hook_runtime/
stop.rs

1//! `spool hook stop` — drain pending signals + scan transcript for
2//! self-tags and incidents + persist via the shared distill pipeline,
3//! then attempt silent knowledge distillation.
4//!
5//! ## Pipeline
6//! 1. Resolve `transcript_path` (priority: `--transcript-path` flag,
7//!    `--hook-input` JSON `transcript_path` field, fallback scan of
8//!    `~/.claude/projects/<sanitized-cwd>/`).
9//! 2. Delegate to [`crate::distill::pipeline::run`] which performs:
10//!    queue drain → self-tag heuristic → extraction heuristic →
11//!    redact + dedupe + LifecycleService writes (accepted +
12//!    candidate).
13//! 3. Attempt silent knowledge distillation: detect clusters of
14//!    related fragments and auto-create candidate knowledge pages
15//!    (actor: `spool-auto-distill`). Failures are logged to stderr
16//!    and never propagate.
17//! 4. Write `<cwd>/.spool/last-stop.unix` marker for debugging.
18//!
19//! Stop hook is the **automatic** entry point for distill. The MCP
20//! `memory_distill_pending` tool exposes the same pipeline as a
21//! manual on-demand entry point (R4a).
22
23use std::path::{Path, PathBuf};
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use anyhow::{Context, Result};
27use serde_json::Value;
28
29use super::project_runtime_dir;
30use crate::config::loader::load_from_path;
31use crate::distill::pipeline::{self, DistillReport, DistillRequest};
32use crate::distill::transcript;
33use crate::engine::project_matcher::match_project;
34use crate::knowledge;
35
36#[derive(Debug, Clone, Default)]
37pub struct StopArgs {
38    pub config_path: PathBuf,
39    pub cwd: Option<PathBuf>,
40    pub transcript_path: Option<PathBuf>,
41    pub hook_input: Option<String>,
42    pub home: Option<PathBuf>,
43}
44
45#[derive(Debug, Clone, Default)]
46pub struct StopReport {
47    pub distill: DistillReport,
48    /// Number of knowledge pages auto-created during silent distillation.
49    pub knowledge_pages_created: usize,
50    /// Record IDs of knowledge page candidates created.
51    pub knowledge_page_ids: Vec<String>,
52}
53
54impl StopReport {
55    pub fn transcript_path(&self) -> Option<&Path> {
56        self.distill.transcript_path.as_deref()
57    }
58
59    pub fn signals_persisted(&self) -> &[String] {
60        &self.distill.signals_persisted
61    }
62
63    pub fn candidates_persisted(&self) -> &[String] {
64        &self.distill.candidates_persisted
65    }
66}
67
68pub fn run(args: StopArgs) -> Result<StopReport> {
69    let cwd = match args.cwd {
70        Some(p) => p,
71        None => std::env::current_dir().context("resolving cwd for stop hook")?,
72    };
73    let runtime_dir = project_runtime_dir(&cwd)?;
74
75    let home = args.home.or_else(crate::support::home_dir);
76    let transcript_path = args
77        .transcript_path
78        .or_else(|| parse_transcript_path_from_hook_input(args.hook_input.as_deref()))
79        .or_else(|| {
80            home.as_deref()
81                .and_then(|h| transcript::find_latest_for_cwd(&cwd, h))
82        });
83
84    let request = DistillRequest::new(args.config_path.clone(), cwd.clone(), transcript_path)
85        .with_actor("spool-hook-stop")
86        .with_source_refs("hook:stop:self-tag", "hook:stop:extraction")
87        .with_project_id(
88            load_from_path(&args.config_path)
89                .ok()
90                .and_then(|cfg| match_project(&cfg, &cwd))
91                .map(|p| p.id),
92        );
93    let distill = pipeline::run(request)?;
94
95    // Silent knowledge distillation: detect clusters and auto-create
96    // candidate knowledge pages. Wrapped in a catch-all so failures
97    // never propagate to the caller (hooks must exit 0).
98    let (knowledge_pages_created, knowledge_page_ids) =
99        run_silent_knowledge_distill(&args.config_path);
100
101    let stamp = SystemTime::now()
102        .duration_since(UNIX_EPOCH)
103        .map(|d| d.as_secs())
104        .unwrap_or(0);
105    let marker_path = runtime_dir.join("last-stop.unix");
106    std::fs::write(&marker_path, stamp.to_string())
107        .with_context(|| format!("writing stop timestamp {}", marker_path.display()))?;
108
109    Ok(StopReport {
110        distill,
111        knowledge_pages_created,
112        knowledge_page_ids,
113    })
114}
115
116/// Attempt silent knowledge distillation. Returns (pages_created, record_ids).
117/// Any error is logged to stderr and swallowed — this must never break the
118/// Stop hook.
119fn run_silent_knowledge_distill(config_path: &Path) -> (usize, Vec<String>) {
120    match try_knowledge_distill(config_path) {
121        Ok((count, ids)) => (count, ids),
122        Err(err) => {
123            eprintln!("[spool hook stop] knowledge distill suppressed: {:#}", err);
124            (0, Vec::new())
125        }
126    }
127}
128
129/// Inner knowledge distillation logic. Detects clusters and persists
130/// candidate knowledge pages.
131fn try_knowledge_distill(config_path: &Path) -> Result<(usize, Vec<String>)> {
132    let drafts = knowledge::detect_knowledge_clusters(config_path)?;
133    if drafts.is_empty() {
134        return Ok((0, Vec::new()));
135    }
136    let ids = knowledge::apply_distill(config_path, &drafts, "spool-auto-distill")?;
137    Ok((ids.len(), ids))
138}
139
140fn parse_transcript_path_from_hook_input(input: Option<&str>) -> Option<PathBuf> {
141    let raw = input?.trim();
142    if raw.is_empty() {
143        return None;
144    }
145    let value: Value = serde_json::from_str(raw).ok()?;
146    value
147        .get("transcript_path")
148        .and_then(|v| v.as_str())
149        .map(PathBuf::from)
150}
151
152#[cfg(test)]
153mod tests {
154    use super::*;
155    use crate::domain::MemoryScope;
156    use crate::lifecycle_service::LifecycleService;
157    use crate::lifecycle_store::{RecordMemoryRequest, TransitionMetadata};
158    use serde_json::{Value, json};
159    use std::fs;
160    use tempfile::tempdir;
161
162    fn fixture_config(temp: &tempfile::TempDir) -> PathBuf {
163        let cfg = temp.path().join("spool.toml");
164        fs::write(&cfg, "[vault]\nroot = \"/tmp\"\n").unwrap();
165        cfg
166    }
167
168    fn write_transcript(path: &Path, entries: &[Value]) {
169        let mut body = String::new();
170        for e in entries {
171            body.push_str(&e.to_string());
172            body.push('\n');
173        }
174        fs::write(path, body).unwrap();
175    }
176
177    #[test]
178    fn run_writes_marker_when_no_transcript() {
179        let temp = tempdir().unwrap();
180        let cfg = fixture_config(&temp);
181        let cwd = temp.path().join("repo");
182        fs::create_dir_all(&cwd).unwrap();
183
184        let report = run(StopArgs {
185            config_path: cfg,
186            cwd: Some(cwd.clone()),
187            transcript_path: None,
188            hook_input: None,
189            home: Some(temp.path().join("fake-home")),
190        })
191        .unwrap();
192
193        assert_eq!(report.distill.signals_detected, 0);
194        assert!(cwd.join(".spool").join("last-stop.unix").exists());
195    }
196
197    #[test]
198    fn run_extracts_self_tag_via_pipeline() {
199        let temp = tempdir().unwrap();
200        let cfg = fixture_config(&temp);
201        let cwd = temp.path().join("repo");
202        fs::create_dir_all(&cwd).unwrap();
203        let transcript_path = temp.path().join("session.jsonl");
204        write_transcript(
205            &transcript_path,
206            &[json!({
207                "type": "user",
208                "message": {"role": "user", "content": "记一下:cargo install 是默认安装路径"}
209            })],
210        );
211
212        let report = run(StopArgs {
213            config_path: cfg.clone(),
214            cwd: Some(cwd),
215            transcript_path: Some(transcript_path),
216            hook_input: None,
217            home: None,
218        })
219        .unwrap();
220        assert_eq!(report.distill.signals_persisted.len(), 1);
221        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
222        assert_eq!(snap.wakeup_ready.len(), 1);
223        assert_eq!(snap.wakeup_ready[0].record.memory_type, "preference");
224    }
225
226    #[test]
227    fn run_skips_assistant_self_tag_attempts() {
228        let temp = tempdir().unwrap();
229        let cfg = fixture_config(&temp);
230        let cwd = temp.path().join("repo");
231        fs::create_dir_all(&cwd).unwrap();
232        let transcript_path = temp.path().join("session.jsonl");
233        write_transcript(
234            &transcript_path,
235            &[json!({
236                "type": "assistant",
237                "message": {"role": "assistant", "content": "记一下:assistant tried to inject"}
238            })],
239        );
240        let report = run(StopArgs {
241            config_path: cfg.clone(),
242            cwd: Some(cwd),
243            transcript_path: Some(transcript_path),
244            hook_input: None,
245            home: None,
246        })
247        .unwrap();
248        assert_eq!(report.distill.signals_detected, 0);
249        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
250        assert!(snap.wakeup_ready.is_empty());
251    }
252
253    #[test]
254    fn run_emits_incident_candidate_for_repeated_frustration() {
255        let temp = tempdir().unwrap();
256        let cfg = fixture_config(&temp);
257        let cwd = temp.path().join("repo");
258        fs::create_dir_all(&cwd).unwrap();
259        let transcript_path = temp.path().join("session.jsonl");
260        write_transcript(
261            &transcript_path,
262            &[
263                json!({"type":"user","message":{"role":"user","content":"试一下 cargo test"}}),
264                json!({"type":"user","message":{"role":"user","content":"还是错了,看看日志"}}),
265                json!({"type":"user","message":{"role":"user","content":"又失败了"}}),
266            ],
267        );
268        let report = run(StopArgs {
269            config_path: cfg.clone(),
270            cwd: Some(cwd),
271            transcript_path: Some(transcript_path),
272            hook_input: None,
273            home: None,
274        })
275        .unwrap();
276        assert_eq!(report.distill.candidates_persisted.len(), 1);
277        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
278        assert_eq!(snap.pending_review.len(), 1);
279        assert_eq!(snap.pending_review[0].record.memory_type, "incident");
280    }
281
282    #[test]
283    fn run_drops_signals_when_redact_finds_secret() {
284        let temp = tempdir().unwrap();
285        let cfg = fixture_config(&temp);
286        let cwd = temp.path().join("repo");
287        fs::create_dir_all(&cwd).unwrap();
288        let transcript_path = temp.path().join("session.jsonl");
289        write_transcript(
290            &transcript_path,
291            &[json!({
292                "type":"user",
293                "message":{"role":"user","content":"记一下: token sk-abcDEFghi1234567890ABCDEFGHIJ for prod"}
294            })],
295        );
296        let report = run(StopArgs {
297            config_path: cfg.clone(),
298            cwd: Some(cwd),
299            transcript_path: Some(transcript_path),
300            hook_input: None,
301            home: None,
302        })
303        .unwrap();
304        assert_eq!(report.distill.signals_detected, 1);
305        assert_eq!(report.distill.signals_redacted_dropped, 1);
306        assert!(report.distill.signals_persisted.is_empty());
307    }
308
309    #[test]
310    fn run_dedupes_against_existing_record() {
311        let temp = tempdir().unwrap();
312        let cfg = fixture_config(&temp);
313        let cwd = temp.path().join("repo");
314        fs::create_dir_all(&cwd).unwrap();
315        LifecycleService::new()
316            .record_manual(
317                &cfg,
318                RecordMemoryRequest {
319                    title: "[seed] x".into(),
320                    summary: "cargo install 是默认安装路径".into(),
321                    memory_type: "preference".into(),
322                    scope: MemoryScope::Project,
323                    source_ref: "manual:seed".into(),
324                    project_id: None,
325                    user_id: None,
326                    sensitivity: None,
327                    metadata: TransitionMetadata::default(),
328                    entities: Vec::new(),
329                    tags: Vec::new(),
330                    triggers: Vec::new(),
331                    related_files: Vec::new(),
332                    related_records: Vec::new(),
333                    supersedes: None,
334                    applies_to: Vec::new(),
335                    valid_until: None,
336                },
337            )
338            .unwrap();
339        let transcript_path = temp.path().join("session.jsonl");
340        write_transcript(
341            &transcript_path,
342            &[json!({
343                "type": "user",
344                "message": {"role": "user", "content": "记一下:cargo install 是默认安装路径"}
345            })],
346        );
347        let report = run(StopArgs {
348            config_path: cfg.clone(),
349            cwd: Some(cwd),
350            transcript_path: Some(transcript_path),
351            hook_input: None,
352            home: None,
353        })
354        .unwrap();
355        assert_eq!(report.distill.signals_duplicate_dropped, 1);
356        assert!(report.distill.signals_persisted.is_empty());
357        let snap = LifecycleService::new().load_workbench(&cfg).unwrap();
358        assert_eq!(snap.wakeup_ready.len(), 1);
359    }
360
361    #[test]
362    fn run_drains_queue_even_without_transcript() {
363        let temp = tempdir().unwrap();
364        let cfg = fixture_config(&temp);
365        let cwd = temp.path().join("repo");
366        fs::create_dir_all(&cwd).unwrap();
367        let runtime_dir = cwd.join(".spool");
368        fs::create_dir_all(&runtime_dir).unwrap();
369        crate::distill_queue::append(
370            &runtime_dir,
371            &crate::distill_queue::DistillSignal {
372                recorded_at: 1,
373                tool_name: Some("Bash".into()),
374                cwd: cwd.display().to_string(),
375                payload: Some("ls".into()),
376            },
377            crate::distill_queue::DEFAULT_LRU_CAP,
378        )
379        .unwrap();
380
381        let report = run(StopArgs {
382            config_path: cfg,
383            cwd: Some(cwd.clone()),
384            transcript_path: None,
385            hook_input: None,
386            home: Some(temp.path().join("fake-home")),
387        })
388        .unwrap();
389        assert_eq!(report.distill.queue_drained, 1);
390        assert!(
391            crate::distill_queue::peek_all(&runtime_dir)
392                .unwrap()
393                .is_empty()
394        );
395    }
396
397    #[test]
398    fn run_resolves_transcript_from_hook_input_json() {
399        let temp = tempdir().unwrap();
400        let cfg = fixture_config(&temp);
401        let cwd = temp.path().join("repo");
402        fs::create_dir_all(&cwd).unwrap();
403        let transcript_path = temp.path().join("session.jsonl");
404        write_transcript(
405            &transcript_path,
406            &[json!({
407                "type": "user",
408                "message": {"role": "user", "content": "记一下: hook-input wired"}
409            })],
410        );
411        let hook_input = json!({
412            "session_id": "abc",
413            "transcript_path": transcript_path.to_string_lossy()
414        })
415        .to_string();
416        let report = run(StopArgs {
417            config_path: cfg.clone(),
418            cwd: Some(cwd),
419            transcript_path: None,
420            hook_input: Some(hook_input),
421            home: None,
422        })
423        .unwrap();
424        assert_eq!(report.distill.signals_persisted.len(), 1);
425    }
426
427    #[test]
428    fn parse_transcript_path_from_hook_input_handles_blanks() {
429        assert!(parse_transcript_path_from_hook_input(None).is_none());
430        assert!(parse_transcript_path_from_hook_input(Some("")).is_none());
431        assert!(parse_transcript_path_from_hook_input(Some("   ")).is_none());
432        assert!(parse_transcript_path_from_hook_input(Some("not json")).is_none());
433    }
434
435    #[test]
436    fn parse_transcript_path_from_hook_input_extracts_field() {
437        let input = json!({"transcript_path": "/abs/x.jsonl", "other": 1}).to_string();
438        let path = parse_transcript_path_from_hook_input(Some(&input)).unwrap();
439        assert_eq!(path, PathBuf::from("/abs/x.jsonl"));
440    }
441
442    #[test]
443    fn report_helpers_expose_inner_distill_fields() {
444        let mut report = StopReport::default();
445        assert!(report.transcript_path().is_none());
446        assert!(report.signals_persisted().is_empty());
447        assert!(report.candidates_persisted().is_empty());
448        report.distill.signals_persisted.push("rid".into());
449        assert_eq!(report.signals_persisted(), &["rid".to_string()]);
450    }
451}