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