Skip to main content

wm_tools/expansion/
session.rs

1//! Session tools — start, checkpoint, recall, end, verify.
2
3#![forbid(unsafe_code)]
4
5use async_trait::async_trait;
6
7use serde_json::{Value, json};
8use std::path::{Path, PathBuf};
9use std::sync::Arc;
10use wm_core::{
11    Context, EffectRow, EpisodicKind, Galaxy, Gana, ProvenanceSource, Resource, Tool, ToolStats,
12};
13use wm_memory::{Memory, MemoryStore};
14
15/// Capture verifiable git state from a repository root.
16///
17/// Returns `None` when the path is not a git repository or `git` is
18/// unavailable — callers degrade gracefully to manual payloads.
19fn capture_git_state(root: &Path) -> Option<Value> {
20    let run = |git_args: &[&str]| -> Option<String> {
21        let out = std::process::Command::new("git")
22            .args(git_args)
23            .current_dir(root)
24            // Index refresh is a write to .git/ — under Landlock confinement
25            // (writes confined to the store root) it would fail, and it is
26            // needless wear for a read-only capture. GIT_OPTIONAL_LOCKS=0
27            // disables the refresh so `status --porcelain` stays truthful.
28            .env("GIT_OPTIONAL_LOCKS", "0")
29            .output()
30            .ok()?;
31        if out.status.success() {
32            Some(String::from_utf8_lossy(&out.stdout).trim().to_string())
33        } else {
34            None
35        }
36    };
37    // A bare directory passes `rev-parse` only if it is inside a work tree;
38    // `--is-inside-work-tree` is the cheap sanity gate.
39    run(&["rev-parse", "--is-inside-work-tree"])?;
40    let commit = run(&["rev-parse", "HEAD"]).unwrap_or_default();
41    let branch = run(&["rev-parse", "--abbrev-ref", "HEAD"]).unwrap_or_default();
42    let dirty_count = run(&["status", "--porcelain"]).map_or(0, |s| s.lines().count());
43    Some(json!({
44        "commit": commit,
45        "branch": branch,
46        "dirty_count": dirty_count,
47    }))
48}
49
50/// Resolve the project root for git capture: explicit arg wins over the
51/// `WM_PROJECT_ROOT` environment variable; empty values are treated unset.
52fn resolve_project_root(args: &Value) -> Option<PathBuf> {
53    args.get("root")
54        .and_then(|v| v.as_str())
55        .filter(|s| !s.is_empty())
56        .map(PathBuf::from)
57        .or_else(|| {
58            std::env::var("WM_PROJECT_ROOT")
59                .ok()
60                .filter(|s| !s.is_empty())
61                .map(PathBuf::from)
62        })
63}
64
65/// Latest session-start id by creation time (`created_at`, not LMDB key
66/// order — see session_ops resolution fix).
67fn latest_session_start(store: &MemoryStore) -> Option<String> {
68    store
69        .scan_all(Galaxy::Sessions)
70        .ok()?
71        .iter()
72        .filter(|m| m.metadata.tags.contains(&"start".to_string()))
73        .max_by_key(|m| m.metadata.created_at)
74        .map(|m| m.metadata.id.to_string())
75}
76
77/// `session.start` — create a new session memory.
78pub struct SessionStartTool {
79    store: Arc<MemoryStore>,
80    stats: ToolStats,
81    effects: EffectRow,
82    search: Option<Arc<wm_memory::SearchEngine>>,
83}
84
85impl SessionStartTool {
86    pub fn new(store: Arc<MemoryStore>) -> Self {
87        Self {
88            store,
89            stats: ToolStats::default(),
90            effects: EffectRow {
91                writes: vec![Resource::Galaxy("sessions".into())],
92                ..Default::default()
93            },
94            search: None,
95        }
96    }
97
98    /// Index writes at write time so `wm status` index health and
99    /// `memory.search` agree with canonical storage without waiting for the
100    /// next startup heal (2026-09-15 review finding).
101    #[must_use]
102    pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
103        self.search = search;
104        self
105    }
106}
107
108#[async_trait]
109impl Tool for SessionStartTool {
110    fn name(&self) -> &str {
111        "session.start"
112    }
113    fn gana(&self) -> Gana {
114        Gana::StraddlingLegs
115    }
116    fn effects(&self) -> &EffectRow {
117        &self.effects
118    }
119    fn input_schema(&self) -> Value {
120        super::common::schema(
121            &json!({
122                "title": super::common::str_prop("Session title"),
123                "user": super::common::str_prop("User identifier (default 'default')"),
124            }),
125            &[],
126        )
127    }
128    fn description(&self) -> &str {
129        "Start a new session — creates a session memory in Sessions galaxy"
130    }
131    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
132        let title = args
133            .get("title")
134            .and_then(|v| v.as_str())
135            .unwrap_or("Untitled Session");
136        let user = args
137            .get("user")
138            .and_then(|v| v.as_str())
139            .unwrap_or("default");
140        let mut mem = Memory::new(
141            Galaxy::Sessions,
142            json!({
143                "type": "session_start",
144                "title": title,
145                "user": user,
146            })
147            .to_string(),
148        );
149        mem.metadata.tags = vec!["session".into(), "start".into()];
150        mem.metadata.importance = 0.7;
151        // Machine-captured event — claims system provenance, never user.
152        mem.metadata.source = "system".to_string();
153        mem.metadata.source_trust = 0.7;
154        self.store.put(Galaxy::Sessions, &mem)?;
155        super::common::index_memory(self.search.as_deref(), &mem);
156        let episodic_capture_error = crate::capture_explicit_memory(
157            &self.store,
158            &mem,
159            EpisodicKind::SystemEvent,
160            ProvenanceSource::System,
161            Some(mem.metadata.id),
162            0,
163        );
164        let mut response = json!({
165            "status": "success",
166            "session_id": mem.metadata.id,
167            "title": title,
168            "user": user,
169        });
170        if let Some(error) = episodic_capture_error {
171            response["warnings"] = json!([format!(
172                "episodic capture failed after the session event was stored: {error}"
173            )]);
174        }
175        Ok(response)
176    }
177    fn stats(&self) -> &ToolStats {
178        &self.stats
179    }
180}
181
182/// `session.checkpoint` — save a checkpoint in a session.
183pub struct SessionCheckpointTool {
184    store: Arc<MemoryStore>,
185    stats: ToolStats,
186    effects: EffectRow,
187    search: Option<Arc<wm_memory::SearchEngine>>,
188}
189
190impl SessionCheckpointTool {
191    pub fn new(store: Arc<MemoryStore>) -> Self {
192        Self {
193            store,
194            stats: ToolStats::default(),
195            effects: EffectRow {
196                writes: vec![Resource::Galaxy("sessions".into())],
197                ..Default::default()
198            },
199            search: None,
200        }
201    }
202
203    /// Index writes at write time so `wm status` index health and
204    /// `memory.search` agree with canonical storage without waiting for the
205    /// next startup heal (2026-09-15 review finding).
206    #[must_use]
207    pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
208        self.search = search;
209        self
210    }
211}
212
213#[async_trait]
214impl Tool for SessionCheckpointTool {
215    fn name(&self) -> &str {
216        "session.checkpoint"
217    }
218    fn gana(&self) -> Gana {
219        Gana::StraddlingLegs
220    }
221    fn effects(&self) -> &EffectRow {
222        &self.effects
223    }
224    fn input_schema(&self) -> Value {
225        super::common::schema(
226            &json!({
227                "session_id": super::common::str_prop("Target session (default: most recent session)"),
228                "label": super::common::str_prop("Checkpoint label (default 'checkpoint')"),
229                "data": {
230                    "type": "object",
231                    "description": "Legacy free-form passthrough stored beside the handoff."
232                },
233                "commit": super::common::str_prop("Manual commit hash (auto-captured from git when root/WM_PROJECT_ROOT is set)"),
234                "branch": super::common::str_prop("Manual branch name (auto-captured when git is available)"),
235                "tests_green": {
236                    "type": "boolean",
237                    "description": "Whether the test suite was green at checkpoint time."
238                },
239                "next_queue": {
240                    "type": "array",
241                    "description": "Ordered next-step strings for the next session."
242                },
243                "open_flags": {
244                    "type": "array",
245                    "description": "Open concerns/flags worth surfacing on resume."
246                },
247                "lease_id": super::common::str_prop("Claimed scope (code.claim lease_id) that remains held at this handoff"),
248                "root": super::common::str_prop("Repository root for auto git-capture (default: WM_PROJECT_ROOT env)"),
249            }),
250            &[],
251        )
252    }
253    fn description(&self) -> &str {
254        "Save a session checkpoint with a verifiable structured handoff: commit, branch, dirty count (auto-captured from git via WM_PROJECT_ROOT), tests_green, next_queue, open_flags, lease_id (a code.claim scope that stays held)."
255    }
256    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
257        let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
258            Some(sid) if !sid.is_empty() => sid.to_string(),
259            _ => latest_session_start(&self.store).ok_or_else(|| {
260                wm_core::CoreError::Tool("no session found — run session.start first".into())
261            })?,
262        };
263        let label = args
264            .get("label")
265            .and_then(|v| v.as_str())
266            .unwrap_or("checkpoint");
267        let data = args.get("data").cloned().unwrap_or_else(|| json!({}));
268
269        // Structured handoff (P0): explicit arguments win; git state is
270        // auto-captured so the common case records truth without effort.
271        let git_state = resolve_project_root(&args).and_then(|root| capture_git_state(&root));
272        let mut handoff = json!({});
273        {
274            let h = handoff.as_object_mut().expect("just created");
275            for (key, value) in [
276                ("commit", args.get("commit")),
277                ("branch", args.get("branch")),
278                ("tests_green", args.get("tests_green")),
279                ("next_queue", args.get("next_queue")),
280                ("open_flags", args.get("open_flags")),
281                ("lease_id", args.get("lease_id")),
282            ] {
283                if value.is_some() {
284                    h.insert(key.to_string(), value.cloned().expect("checked above"));
285                }
286            }
287            if let Some(git) = git_state {
288                h.insert("git".to_string(), git);
289            }
290        }
291
292        let mut mem = Memory::new(
293            Galaxy::Sessions,
294            json!({
295                "type": "checkpoint",
296                "session_id": session_id,
297                "label": label,
298                "data": data,
299                "handoff": handoff,
300            })
301            .to_string(),
302        );
303        mem.metadata.tags = vec!["session".into(), "checkpoint".into()];
304        mem.metadata.importance = 0.5;
305        // Machine-captured event — claims system provenance, never user.
306        mem.metadata.source = "system".to_string();
307        mem.metadata.source_trust = 0.7;
308        self.store.put(Galaxy::Sessions, &mem)?;
309        super::common::index_memory(self.search.as_deref(), &mem);
310        let episodic_capture_error = crate::capture_explicit_memory(
311            &self.store,
312            &mem,
313            EpisodicKind::SystemEvent,
314            ProvenanceSource::System,
315            uuid::Uuid::parse_str(&session_id).ok(),
316            0,
317        );
318        let mut response = json!({
319            "status": "success",
320            "checkpoint_id": mem.metadata.id,
321            "session_id": session_id,
322            "label": label,
323            "handoff": handoff,
324        });
325        if let Some(error) = episodic_capture_error {
326            response["warnings"] = json!([format!(
327                "episodic capture failed after the checkpoint was stored: {error}"
328            )]);
329        }
330        Ok(response)
331    }
332    fn stats(&self) -> &ToolStats {
333        &self.stats
334    }
335}
336
337/// `session.verify` — grade stored checkpoint state against live git reality.
338///
339/// Self-correcting memory: the checkpoint asserted "HEAD was X, N files
340/// dirty"; this compares that assertion to the repository now and reports
341/// drift (commits ahead, dirty-count delta) so a future session knows how
342/// much it can trust the handoff before acting on it.
343pub struct SessionVerifyTool {
344    store: Arc<MemoryStore>,
345    stats: ToolStats,
346    effects: EffectRow,
347}
348
349impl SessionVerifyTool {
350    pub fn new(store: Arc<MemoryStore>) -> Self {
351        Self {
352            store,
353            stats: ToolStats::default(),
354            effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
355        }
356    }
357}
358
359#[async_trait]
360impl Tool for SessionVerifyTool {
361    fn name(&self) -> &str {
362        "session.verify"
363    }
364    fn gana(&self) -> Gana {
365        Gana::StraddlingLegs
366    }
367    fn effects(&self) -> &EffectRow {
368        &self.effects
369    }
370    fn input_schema(&self) -> Value {
371        super::common::schema(
372            &json!({
373                "session_id": super::common::str_prop("Session whose latest checkpoint to verify (default: most recent session)"),
374                "root": super::common::str_prop("Repository root to verify against (default: WM_PROJECT_ROOT env)"),
375            }),
376            &[],
377        )
378    }
379    fn description(&self) -> &str {
380        "Verify a session's stored checkpoint against live git state — reports commit drift and dirty-count delta ('your memory says HEAD was X; git says Y')."
381    }
382    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
383        let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
384            Some(sid) if !sid.is_empty() => sid.to_string(),
385            _ => latest_session_start(&self.store).ok_or_else(|| {
386                wm_core::CoreError::Tool("no session found — run session.start first".into())
387            })?,
388        };
389
390        // Latest verifiable checkpoint for this session: handoff.git.commit
391        // present. Checkpoints from before structured handoffs are skipped.
392        let memories = self.store.scan_all(Galaxy::Sessions)?;
393        let stored = memories
394            .iter()
395            .filter(|m| {
396                m.metadata.tags.contains(&"checkpoint".to_string())
397                    && m.content.contains(&session_id)
398            })
399            .filter_map(|m| {
400                let parsed: Value = serde_json::from_str(&m.content).ok()?;
401                let git = parsed.get("handoff")?.get("git")?.clone();
402                if git.get("commit").and_then(Value::as_str).is_some() {
403                    Some((m.metadata.id.to_string(), m.metadata.created_at, git))
404                } else {
405                    None
406                }
407            })
408            .max_by_key(|(_, created_at, _)| *created_at);
409
410        let Some((checkpoint_id, _, stored_git)) = stored else {
411            return Ok(json!({
412                "status": "success",
413                "verifiable": false,
414                "message": "no checkpoint with captured git state found for this session — checkpoint with WM_PROJECT_ROOT set (or an explicit root) to enable verification"
415            }));
416        };
417
418        let Some(root) = resolve_project_root(&args) else {
419            return Ok(json!({
420                "status": "error",
421                "checkpoint_id": checkpoint_id,
422                "stored_git": stored_git,
423                "message": "no repository root available — pass 'root' or set WM_PROJECT_ROOT to verify against live git"
424            }));
425        };
426        let Some(current_git) = capture_git_state(&root) else {
427            return Ok(json!({
428                "status": "error",
429                "checkpoint_id": checkpoint_id,
430                "stored_git": stored_git,
431                "message": format!("'{}' is not a usable git work tree", root.display())
432            }));
433        };
434
435        let stored_commit = stored_git["commit"].as_str().unwrap_or_default();
436        let current_commit = current_git["commit"].as_str().unwrap_or_default();
437        let commits_ahead = if stored_commit == current_commit {
438            Some(0)
439        } else {
440            std::process::Command::new("git")
441                .args(["rev-list", "--count", &format!("{stored_commit}..HEAD")])
442                .current_dir(&root)
443                .output()
444                .ok()
445                .filter(|o| o.status.success())
446                .and_then(|o| {
447                    String::from_utf8_lossy(&o.stdout)
448                        .trim()
449                        .parse::<u64>()
450                        .ok()
451                })
452        };
453        let dirty_delta = current_git["dirty_count"].as_i64().unwrap_or(0)
454            - stored_git["dirty_count"].as_i64().unwrap_or(0);
455
456        let verdict = if stored_commit == current_commit && dirty_delta == 0 {
457            "clean"
458        } else if stored_commit == current_commit {
459            "dirty-drift"
460        } else {
461            "drifted"
462        };
463
464        Ok(json!({
465            "status": "success",
466            "verifiable": true,
467            "session_id": session_id,
468            "checkpoint_id": checkpoint_id,
469            "stored_git": stored_git,
470            "current_git": current_git,
471            "commits_ahead": commits_ahead,
472            "dirty_delta": dirty_delta,
473            "verdict": verdict,
474        }))
475    }
476    fn stats(&self) -> &ToolStats {
477        &self.stats
478    }
479}
480
481/// `session.recall` — retrieve session memories.
482pub struct SessionRecallTool {
483    store: Arc<MemoryStore>,
484    stats: ToolStats,
485    effects: EffectRow,
486}
487
488impl SessionRecallTool {
489    pub fn new(store: Arc<MemoryStore>) -> Self {
490        Self {
491            store,
492            stats: ToolStats::default(),
493            effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
494        }
495    }
496}
497
498#[async_trait]
499impl Tool for SessionRecallTool {
500    fn input_schema(&self) -> Value {
501        super::common::schema(
502            &json!({
503                "session_id": super::common::str_prop("Session UUID to recall (defaults to the most recent session)"),
504                "limit": super::common::int_prop("Maximum turns to return (default 50)"),
505            }),
506            &[],
507        )
508    }
509    fn name(&self) -> &str {
510        "session.recall"
511    }
512    fn gana(&self) -> Gana {
513        Gana::StraddlingLegs
514    }
515    fn effects(&self) -> &EffectRow {
516        &self.effects
517    }
518    fn description(&self) -> &str {
519        "Recall session memories by session_id"
520    }
521    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
522        let session_id = args
523            .get("session_id")
524            .and_then(|v| v.as_str())
525            .unwrap_or("");
526        let limit = args
527            .get("limit")
528            .and_then(serde_json::Value::as_u64)
529            .unwrap_or(50) as usize;
530        let memories = self.store.scan_all(Galaxy::Sessions)?;
531        let filtered: Vec<Value> = memories
532            .iter()
533            .filter(|m| m.content.contains(session_id))
534            .take(limit)
535            .map(|m| {
536                json!({
537                    "id": m.metadata.id,
538                    "content": m.content,
539                    "tags": m.metadata.tags,
540                    "created_at": m.metadata.created_at.to_rfc3339(),
541                })
542            })
543            .collect();
544        Ok(json!({
545            "status": "success",
546            "session_id": session_id,
547            "count": filtered.len(),
548            "memories": filtered,
549        }))
550    }
551    fn stats(&self) -> &ToolStats {
552        &self.stats
553    }
554}
555
556/// `session.end` — end a session.
557pub struct SessionEndTool {
558    store: Arc<MemoryStore>,
559    stats: ToolStats,
560    effects: EffectRow,
561    search: Option<Arc<wm_memory::SearchEngine>>,
562}
563
564impl SessionEndTool {
565    pub fn new(store: Arc<MemoryStore>) -> Self {
566        Self {
567            store,
568            stats: ToolStats::default(),
569            effects: EffectRow {
570                writes: vec![Resource::Galaxy("sessions".into())],
571                ..Default::default()
572            },
573            search: None,
574        }
575    }
576
577    /// Index writes at write time so `wm status` index health and
578    /// `memory.search` agree with canonical storage without waiting for the
579    /// next startup heal (2026-09-15 review finding).
580    #[must_use]
581    pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
582        self.search = search;
583        self
584    }
585}
586
587#[async_trait]
588impl Tool for SessionEndTool {
589    fn name(&self) -> &str {
590        "session.end"
591    }
592    fn gana(&self) -> Gana {
593        Gana::StraddlingLegs
594    }
595    fn effects(&self) -> &EffectRow {
596        &self.effects
597    }
598    fn description(&self) -> &str {
599        "End a session — writes a session_end marker"
600    }
601    fn input_schema(&self) -> Value {
602        super::common::schema(
603            &json!({
604                "session_id": super::common::str_prop("Session UUID to end (start-memory id)"),
605                "summary": super::common::str_prop("Optional closing summary"),
606            }),
607            &["session_id"],
608        )
609    }
610    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
611        let session_id = args
612            .get("session_id")
613            .and_then(|v| v.as_str())
614            .unwrap_or("");
615        let summary = args.get("summary").and_then(|v| v.as_str()).unwrap_or("");
616        let mut mem = Memory::new(
617            Galaxy::Sessions,
618            json!({
619                "type": "session_end",
620                "session_id": session_id,
621                "summary": summary,
622            })
623            .to_string(),
624        );
625        mem.metadata.tags = vec!["session".into(), "end".into()];
626        mem.metadata.importance = 0.6;
627        // Machine-captured event — claims system provenance, never user.
628        mem.metadata.source = "system".to_string();
629        mem.metadata.source_trust = 0.7;
630        self.store.put(Galaxy::Sessions, &mem)?;
631        super::common::index_memory(self.search.as_deref(), &mem);
632        let episodic_capture_error = crate::capture_explicit_memory(
633            &self.store,
634            &mem,
635            EpisodicKind::SystemEvent,
636            ProvenanceSource::System,
637            uuid::Uuid::parse_str(session_id).ok(),
638            0,
639        );
640        let mut response = json!({
641            "status": "success",
642            "session_id": session_id,
643            "end_id": mem.metadata.id,
644        });
645        if let Some(error) = episodic_capture_error {
646            response["warnings"] = json!([format!(
647                "episodic capture failed after the session event was stored: {error}"
648            )]);
649        }
650        Ok(response)
651    }
652    fn stats(&self) -> &ToolStats {
653        &self.stats
654    }
655}
656
657#[cfg(test)]
658mod tests {
659    use super::*;
660
661    fn test_store() -> Arc<MemoryStore> {
662        let dir = tempfile::tempdir().unwrap();
663        let path = dir.path().join("lmdb");
664        std::fs::create_dir_all(&path).unwrap();
665        Arc::new(MemoryStore::open_default(path).unwrap())
666    }
667
668    fn start_session(store: &MemoryStore) -> String {
669        let mut mem = Memory::new(
670            Galaxy::Sessions,
671            json!({"type": "session_start"}).to_string(),
672        );
673        mem.metadata.tags = vec!["session".into(), "start".into()];
674        store.put(Galaxy::Sessions, &mem).unwrap();
675        mem.metadata.id.to_string()
676    }
677
678    /// Fresh local git repository with one empty initial commit — returns
679    /// its path.
680    fn git_repo() -> (tempfile::TempDir, PathBuf) {
681        let dir = tempfile::tempdir().unwrap();
682        let root = dir.path().to_path_buf();
683        let run = |args: &[&str]| {
684            std::process::Command::new("git")
685                .args(args)
686                .current_dir(&root)
687                .output()
688                .expect("git must be available")
689        };
690        assert!(run(&["init", "-q"]).status.success());
691        assert!(run(&["config", "user.email", "t@t"]).status.success());
692        assert!(run(&["config", "user.name", "t"]).status.success());
693        assert!(
694            run(&["commit", "--allow-empty", "-m", "c1"])
695                .status
696                .success()
697        );
698        (dir, root)
699    }
700
701    /// Machine events claim system provenance (never user) — the
702    /// sessions-galaxy attribution fix (2026-08-29).
703    #[tokio::test]
704    async fn start_marker_stamps_system_provenance() {
705        let store = test_store();
706        let tool = SessionStartTool::new(store.clone());
707        let mut ctx = Context::default();
708        let out = tool
709            .call(&mut ctx, json!({"title": "prov test"}))
710            .await
711            .unwrap();
712        let sid = uuid::Uuid::parse_str(out["session_id"].as_str().unwrap()).unwrap();
713        let mem = store
714            .get(Galaxy::Sessions, sid)
715            .expect("start stored")
716            .expect("start present");
717        assert_eq!(mem.metadata.source, "system");
718        assert!((mem.metadata.source_trust - 0.7).abs() < 1e-5);
719    }
720
721    #[tokio::test]
722    async fn checkpoint_auto_captures_git_state() {
723        let store = test_store();
724        let sid = start_session(&store);
725        let (_guard, root) = git_repo();
726
727        let tool = SessionCheckpointTool::new(store.clone());
728        let mut ctx = Context::default();
729        let r = tool
730            .call(
731                &mut ctx,
732                json!({"session_id": sid, "root": root.display().to_string(), "tests_green": true}),
733            )
734            .await
735            .unwrap();
736
737        assert_eq!(r["status"], "success");
738        let git = &r["handoff"]["git"];
739        let expected = String::from_utf8(
740            std::process::Command::new("git")
741                .args(["rev-parse", "HEAD"])
742                .current_dir(&root)
743                .output()
744                .unwrap()
745                .stdout,
746        )
747        .unwrap();
748        assert_eq!(
749            git["commit"].as_str().unwrap().trim(),
750            expected.trim(),
751            "checkpoint must auto-capture the live HEAD"
752        );
753        assert_eq!(git["dirty_count"], 0);
754        assert_eq!(r["handoff"]["tests_green"], true);
755    }
756
757    #[tokio::test]
758    async fn checkpoint_resolves_latest_session_when_absent() {
759        let store = test_store();
760        let _old = start_session(&store);
761        let newest = start_session(&store);
762
763        let tool = SessionCheckpointTool::new(store);
764        let mut ctx = Context::default();
765        let r = tool.call(&mut ctx, json!({"label": "wrap"})).await.unwrap();
766
767        assert_eq!(r["status"], "success");
768        assert_eq!(r["session_id"], newest, "must target the newest session");
769    }
770
771    #[tokio::test]
772    async fn verify_reports_clean_then_drifted() {
773        let store = test_store();
774        let sid = start_session(&store);
775        let (dir_guard, root) = git_repo();
776        let root_str = root.display().to_string();
777
778        let cp = SessionCheckpointTool::new(store.clone());
779        let mut ctx = Context::default();
780        cp.call(&mut ctx, json!({"session_id": sid, "root": root_str}))
781            .await
782            .unwrap();
783
784        let verify = SessionVerifyTool::new(store.clone());
785        let clean = verify
786            .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
787            .await
788            .unwrap();
789        assert_eq!(clean["verifiable"], true);
790        assert_eq!(clean["verdict"], "clean", "got: {clean}");
791        assert_eq!(clean["commits_ahead"], 0);
792
793        // Land a second commit behind the checkpoint's back.
794        assert!(
795            std::process::Command::new("git")
796                .args(["commit", "--allow-empty", "-m", "c2"])
797                .current_dir(&root)
798                .output()
799                .unwrap()
800                .status
801                .success()
802        );
803
804        let drifted = verify
805            .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
806            .await
807            .unwrap();
808        assert_eq!(drifted["verdict"], "drifted", "got: {drifted}");
809        assert_eq!(drifted["commits_ahead"], 1);
810        assert_ne!(
811            drifted["stored_git"]["commit"],
812            drifted["current_git"]["commit"]
813        );
814
815        drop(dir_guard);
816    }
817
818    #[tokio::test]
819    async fn checkpoint_carries_lease_id_in_handoff() {
820        let store = test_store();
821        let sid = start_session(&store);
822
823        let tool = SessionCheckpointTool::new(store);
824        let mut ctx = Context::default();
825        let r = tool
826            .call(
827                &mut ctx,
828                json!({"session_id": sid, "lease_id": "src/expansion/"}),
829            )
830            .await
831            .unwrap();
832
833        assert_eq!(r["status"], "success");
834        assert_eq!(r["handoff"]["lease_id"], "src/expansion/");
835    }
836
837    #[tokio::test]
838    async fn verify_reports_unverifiable_without_git_checkpoint() {
839        let store = test_store();
840        let sid = start_session(&store);
841
842        // Legacy-style checkpoint: data passthrough only, no handoff.git.
843        let cp = SessionCheckpointTool::new(store.clone());
844        let mut ctx = Context::default();
845        // NOTE: no `root` arg and WM_PROJECT_ROOT unset in the test env.
846        let r = cp.call(&mut ctx, json!({"session_id": sid})).await.unwrap();
847        assert!(r["handoff"]["git"].is_null());
848
849        let verify = SessionVerifyTool::new(store);
850        let v = verify
851            .call(&mut ctx, json!({"session_id": sid}))
852            .await
853            .unwrap();
854        assert_eq!(v["verifiable"], false, "got: {v}");
855        assert!(v["message"].as_str().unwrap().contains("no checkpoint"));
856    }
857}