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/// Shared checkpoint write path: store the record, index it, mirror to
183/// episodic, and build the response. Both checkpoint variants use it so
184/// their persistence semantics stay identical.
185fn store_checkpoint_record(
186    store: &MemoryStore,
187    search: Option<&wm_memory::SearchEngine>,
188    session_id: &str,
189    label: &str,
190    data: &Value,
191    handoff: &Value,
192) -> wm_core::Result<Value> {
193    let mut mem = Memory::new(
194        Galaxy::Sessions,
195        json!({
196            "type": "checkpoint",
197            "session_id": session_id,
198            "label": label,
199            "data": data,
200            "handoff": handoff,
201        })
202        .to_string(),
203    );
204    mem.metadata.tags = vec!["session".into(), "checkpoint".into()];
205    mem.metadata.importance = 0.5;
206    // Machine-captured event — claims system provenance, never user.
207    mem.metadata.source = "system".to_string();
208    mem.metadata.source_trust = 0.7;
209    store.put(Galaxy::Sessions, &mem)?;
210    super::common::index_memory(search, &mem);
211    let episodic_capture_error = crate::capture_explicit_memory(
212        store,
213        &mem,
214        EpisodicKind::SystemEvent,
215        ProvenanceSource::System,
216        uuid::Uuid::parse_str(session_id).ok(),
217        0,
218    );
219    let mut response = json!({
220        "status": "success",
221        "checkpoint_id": mem.metadata.id,
222        "session_id": session_id,
223        "label": label,
224        "handoff": handoff,
225    });
226    if let Some(error) = episodic_capture_error {
227        response["warnings"] = json!([format!(
228            "episodic capture failed after the checkpoint was stored: {error}"
229        )]);
230    }
231    Ok(response)
232}
233
234/// `session.checkpoint` — save a checkpoint in a session.
235pub struct SessionCheckpointTool {
236    store: Arc<MemoryStore>,
237    stats: ToolStats,
238    effects: EffectRow,
239    search: Option<Arc<wm_memory::SearchEngine>>,
240}
241
242impl SessionCheckpointTool {
243    pub fn new(store: Arc<MemoryStore>) -> Self {
244        Self {
245            store,
246            stats: ToolStats::default(),
247            // Truthful declaration (AHIMSA Target A, 9.1.8): the git capture
248            // reads the filesystem and spawns fixed `git` subprocesses, so the
249            // rich checkpoint declares both (Process satisfies the effect
250            // audit's spawn requirement). Strict mode refuses it; the
251            // no-discovery variant (`session.checkpoint_nodiscovery`) is the
252            // form available under stress. Writes already gate Alpha/Theta,
253            // so the spawn declaration adds no availability cost.
254            effects: EffectRow {
255                reads: vec![Resource::Filesystem, Resource::Process],
256                writes: vec![Resource::Galaxy("sessions".into())],
257                spawns: true,
258                ..Default::default()
259            },
260            search: None,
261        }
262    }
263
264    /// Index writes at write time so `wm status` index health and
265    /// `memory.search` agree with canonical storage without waiting for the
266    /// next startup heal (2026-09-15 review finding).
267    #[must_use]
268    pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
269        self.search = search;
270        self
271    }
272}
273
274#[async_trait]
275impl Tool for SessionCheckpointTool {
276    fn name(&self) -> &str {
277        "session.checkpoint"
278    }
279    fn gana(&self) -> Gana {
280        Gana::StraddlingLegs
281    }
282    fn effects(&self) -> &EffectRow {
283        &self.effects
284    }
285    fn input_schema(&self) -> Value {
286        super::common::schema(
287            &json!({
288                "session_id": super::common::str_prop("Target session (default: most recent session)"),
289                "label": super::common::str_prop("Checkpoint label (default 'checkpoint')"),
290                "data": {
291                    "type": "object",
292                    "description": "Legacy free-form passthrough stored beside the handoff."
293                },
294                "commit": super::common::str_prop("Manual commit hash (auto-captured from git when root/WM_PROJECT_ROOT is set)"),
295                "branch": super::common::str_prop("Manual branch name (auto-captured when git is available)"),
296                "tests_green": {
297                    "type": "boolean",
298                    "description": "Whether the test suite was green at checkpoint time."
299                },
300                "next_queue": {
301                    "type": "array",
302                    "description": "Ordered next-step strings for the next session."
303                },
304                "open_flags": {
305                    "type": "array",
306                    "description": "Open concerns/flags worth surfacing on resume."
307                },
308                "lease_id": super::common::str_prop("Claimed scope (code.claim lease_id) that remains held at this handoff"),
309                "root": super::common::str_prop("Repository root for auto git-capture (default: WM_PROJECT_ROOT env)"),
310            }),
311            &[],
312        )
313    }
314    fn description(&self) -> &str {
315        "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)."
316    }
317    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
318        let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
319            Some(sid) if !sid.is_empty() => sid.to_string(),
320            _ => latest_session_start(&self.store).ok_or_else(|| {
321                wm_core::CoreError::Tool("no session found — run session.start first".into())
322            })?,
323        };
324        let label = args
325            .get("label")
326            .and_then(|v| v.as_str())
327            .unwrap_or("checkpoint");
328        let data = args.get("data").cloned().unwrap_or_else(|| json!({}));
329
330        // Structured handoff (P0): explicit arguments win; git state is
331        // auto-captured so the common case records truth without effort.
332        let git_state = resolve_project_root(&args).and_then(|root| capture_git_state(&root));
333        let mut handoff = json!({});
334        {
335            let h = handoff.as_object_mut().expect("just created");
336            for (key, value) in [
337                ("commit", args.get("commit")),
338                ("branch", args.get("branch")),
339                ("tests_green", args.get("tests_green")),
340                ("next_queue", args.get("next_queue")),
341                ("open_flags", args.get("open_flags")),
342                ("lease_id", args.get("lease_id")),
343            ] {
344                if value.is_some() {
345                    h.insert(key.to_string(), value.cloned().expect("checked above"));
346                }
347            }
348            if let Some(git) = git_state {
349                h.insert("git".to_string(), git);
350            }
351        }
352
353        store_checkpoint_record(
354            &self.store,
355            self.search.as_deref(),
356            &session_id,
357            label,
358            &data,
359            &handoff,
360        )
361    }
362    fn stats(&self) -> &ToolStats {
363        &self.stats
364    }
365}
366
367/// `session.checkpoint_nodiscovery` — the AHIMSA no-discovery checkpoint.
368///
369/// Stores exactly the caller-supplied handoff fields (commit, branch,
370/// tests_green, next_queue, open_flags, lease_id): no repository discovery,
371/// no filesystem read, no subprocess spawn. Strict mode admits this operation
372/// so continuity survives system stress; the git-capturing
373/// `session.checkpoint` declares its spawns truthfully and is refused there.
374/// Checkpoint and release are independent tools: failure of either does not
375/// block the other.
376pub struct SessionCheckpointNodiscoveryTool {
377    store: Arc<MemoryStore>,
378    stats: ToolStats,
379    effects: EffectRow,
380    search: Option<Arc<wm_memory::SearchEngine>>,
381}
382
383impl SessionCheckpointNodiscoveryTool {
384    pub fn new(store: Arc<MemoryStore>) -> Self {
385        Self {
386            store,
387            stats: ToolStats::default(),
388            // Exactly one Sessions write; no reads, no spawns — this is the
389            // only checkpoint shape the strict gate admits.
390            effects: EffectRow {
391                writes: vec![Resource::Galaxy("sessions".into())],
392                ..Default::default()
393            },
394            search: None,
395        }
396    }
397
398    /// Attach the search engine so the checkpoint is indexed at write time.
399    #[must_use]
400    pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
401        self.search = search;
402        self
403    }
404}
405
406#[async_trait]
407impl Tool for SessionCheckpointNodiscoveryTool {
408    fn name(&self) -> &str {
409        "session.checkpoint_nodiscovery"
410    }
411    fn gana(&self) -> Gana {
412        Gana::StraddlingLegs
413    }
414    fn effects(&self) -> &EffectRow {
415        &self.effects
416    }
417    fn input_schema(&self) -> Value {
418        super::common::schema(
419            &json!({
420                "session_id": super::common::str_prop("Target session (default: most recent session)"),
421                "label": super::common::str_prop("Checkpoint label (default 'checkpoint')"),
422                "commit": super::common::str_prop("Caller-supplied commit hash (no discovery is performed)"),
423                "branch": super::common::str_prop("Caller-supplied branch name"),
424                "tests_green": {
425                    "type": "boolean",
426                    "description": "Whether the test suite was green at checkpoint time."
427                },
428                "next_queue": {
429                    "type": "array",
430                    "description": "Ordered next-step strings for the next session."
431                },
432                "open_flags": {
433                    "type": "array",
434                    "description": "Open concerns/flags worth surfacing on resume."
435                },
436                "lease_id": super::common::str_prop("Claimed scope (code.claim lease_id) that remains held at this handoff"),
437                "data": {
438                    "type": "object",
439                    "description": "Legacy free-form passthrough stored beside the handoff."
440                },
441            }),
442            &[],
443        )
444    }
445    fn description(&self) -> &str {
446        "Store a no-discovery session checkpoint: exactly the supplied handoff fields — no repository discovery, no filesystem read, no subprocess. Available under AHIMSA strict mode; git auto-capture is deliberately absent (use session.checkpoint when git capture is wanted and allowed)."
447    }
448    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
449        let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
450            Some(sid) if !sid.is_empty() => sid.to_string(),
451            _ => latest_session_start(&self.store).ok_or_else(|| {
452                wm_core::CoreError::Tool("no session found — run session.start first".into())
453            })?,
454        };
455        let label = args
456            .get("label")
457            .and_then(|v| v.as_str())
458            .unwrap_or("checkpoint");
459        let data = args.get("data").cloned().unwrap_or_else(|| json!({}));
460
461        // Caller-supplied fields only — no resolve_project_root, no
462        // capture_git_state, no filesystem/subprocess access of any kind.
463        let mut handoff = json!({});
464        {
465            let h = handoff.as_object_mut().expect("just created");
466            for (key, value) in [
467                ("commit", args.get("commit")),
468                ("branch", args.get("branch")),
469                ("tests_green", args.get("tests_green")),
470                ("next_queue", args.get("next_queue")),
471                ("open_flags", args.get("open_flags")),
472                ("lease_id", args.get("lease_id")),
473            ] {
474                if value.is_some() {
475                    h.insert(key.to_string(), value.cloned().expect("checked above"));
476                }
477            }
478        }
479
480        store_checkpoint_record(
481            &self.store,
482            self.search.as_deref(),
483            &session_id,
484            label,
485            &data,
486            &handoff,
487        )
488    }
489    fn stats(&self) -> &ToolStats {
490        &self.stats
491    }
492}
493
494/// `session.verify` — grade stored checkpoint state against live git reality.
495///
496/// Self-correcting memory: the checkpoint asserted "HEAD was X, N files
497/// dirty"; this compares that assertion to the repository now and reports
498/// drift (commits ahead, dirty-count delta) so a future session knows how
499/// much it can trust the handoff before acting on it.
500pub struct SessionVerifyTool {
501    store: Arc<MemoryStore>,
502    stats: ToolStats,
503    effects: EffectRow,
504}
505
506impl SessionVerifyTool {
507    pub fn new(store: Arc<MemoryStore>) -> Self {
508        Self {
509            store,
510            stats: ToolStats::default(),
511            effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
512        }
513    }
514}
515
516#[async_trait]
517impl Tool for SessionVerifyTool {
518    fn name(&self) -> &str {
519        "session.verify"
520    }
521    fn gana(&self) -> Gana {
522        Gana::StraddlingLegs
523    }
524    fn effects(&self) -> &EffectRow {
525        &self.effects
526    }
527    fn input_schema(&self) -> Value {
528        super::common::schema(
529            &json!({
530                "session_id": super::common::str_prop("Session whose latest checkpoint to verify (default: most recent session)"),
531                "root": super::common::str_prop("Repository root to verify against (default: WM_PROJECT_ROOT env)"),
532            }),
533            &[],
534        )
535    }
536    fn description(&self) -> &str {
537        "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')."
538    }
539    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
540        let session_id = match args.get("session_id").and_then(|v| v.as_str()) {
541            Some(sid) if !sid.is_empty() => sid.to_string(),
542            _ => latest_session_start(&self.store).ok_or_else(|| {
543                wm_core::CoreError::Tool("no session found — run session.start first".into())
544            })?,
545        };
546
547        // Latest verifiable checkpoint for this session: handoff.git.commit
548        // present. Checkpoints from before structured handoffs are skipped.
549        let memories = self.store.scan_all(Galaxy::Sessions)?;
550        let stored = memories
551            .iter()
552            .filter(|m| {
553                m.metadata.tags.contains(&"checkpoint".to_string())
554                    && m.content.contains(&session_id)
555            })
556            .filter_map(|m| {
557                let parsed: Value = serde_json::from_str(&m.content).ok()?;
558                let git = parsed.get("handoff")?.get("git")?.clone();
559                if git.get("commit").and_then(Value::as_str).is_some() {
560                    Some((m.metadata.id.to_string(), m.metadata.created_at, git))
561                } else {
562                    None
563                }
564            })
565            .max_by_key(|(_, created_at, _)| *created_at);
566
567        let Some((checkpoint_id, _, stored_git)) = stored else {
568            return Ok(json!({
569                "status": "success",
570                "verifiable": false,
571                "message": "no checkpoint with captured git state found for this session — checkpoint with WM_PROJECT_ROOT set (or an explicit root) to enable verification"
572            }));
573        };
574
575        let Some(root) = resolve_project_root(&args) else {
576            return Ok(json!({
577                "status": "error",
578                "checkpoint_id": checkpoint_id,
579                "stored_git": stored_git,
580                "message": "no repository root available — pass 'root' or set WM_PROJECT_ROOT to verify against live git"
581            }));
582        };
583        let Some(current_git) = capture_git_state(&root) else {
584            return Ok(json!({
585                "status": "error",
586                "checkpoint_id": checkpoint_id,
587                "stored_git": stored_git,
588                "message": format!("'{}' is not a usable git work tree", root.display())
589            }));
590        };
591
592        let stored_commit = stored_git["commit"].as_str().unwrap_or_default();
593        let current_commit = current_git["commit"].as_str().unwrap_or_default();
594        let commits_ahead = if stored_commit == current_commit {
595            Some(0)
596        } else {
597            std::process::Command::new("git")
598                .args(["rev-list", "--count", &format!("{stored_commit}..HEAD")])
599                .current_dir(&root)
600                .output()
601                .ok()
602                .filter(|o| o.status.success())
603                .and_then(|o| {
604                    String::from_utf8_lossy(&o.stdout)
605                        .trim()
606                        .parse::<u64>()
607                        .ok()
608                })
609        };
610        let dirty_delta = current_git["dirty_count"].as_i64().unwrap_or(0)
611            - stored_git["dirty_count"].as_i64().unwrap_or(0);
612
613        let verdict = if stored_commit == current_commit && dirty_delta == 0 {
614            "clean"
615        } else if stored_commit == current_commit {
616            "dirty-drift"
617        } else {
618            "drifted"
619        };
620
621        Ok(json!({
622            "status": "success",
623            "verifiable": true,
624            "session_id": session_id,
625            "checkpoint_id": checkpoint_id,
626            "stored_git": stored_git,
627            "current_git": current_git,
628            "commits_ahead": commits_ahead,
629            "dirty_delta": dirty_delta,
630            "verdict": verdict,
631        }))
632    }
633    fn stats(&self) -> &ToolStats {
634        &self.stats
635    }
636}
637
638/// `session.recall` — retrieve session memories.
639pub struct SessionRecallTool {
640    store: Arc<MemoryStore>,
641    stats: ToolStats,
642    effects: EffectRow,
643}
644
645impl SessionRecallTool {
646    pub fn new(store: Arc<MemoryStore>) -> Self {
647        Self {
648            store,
649            stats: ToolStats::default(),
650            effects: EffectRow::read_only(vec![Resource::Galaxy("sessions".into())]),
651        }
652    }
653}
654
655#[async_trait]
656impl Tool for SessionRecallTool {
657    fn input_schema(&self) -> Value {
658        super::common::schema(
659            &json!({
660                "session_id": super::common::str_prop("Session UUID to recall (defaults to the most recent session)"),
661                "limit": super::common::int_prop("Maximum turns to return (default 50)"),
662            }),
663            &[],
664        )
665    }
666    fn name(&self) -> &str {
667        "session.recall"
668    }
669    fn gana(&self) -> Gana {
670        Gana::StraddlingLegs
671    }
672    fn effects(&self) -> &EffectRow {
673        &self.effects
674    }
675    fn description(&self) -> &str {
676        "Recall session memories by session_id"
677    }
678    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
679        let session_id = args
680            .get("session_id")
681            .and_then(|v| v.as_str())
682            .unwrap_or("");
683        let limit = args
684            .get("limit")
685            .and_then(serde_json::Value::as_u64)
686            .unwrap_or(50) as usize;
687        let memories = self.store.scan_all(Galaxy::Sessions)?;
688        let filtered: Vec<Value> = memories
689            .iter()
690            .filter(|m| m.content.contains(session_id))
691            .take(limit)
692            .map(|m| {
693                json!({
694                    "id": m.metadata.id,
695                    "content": m.content,
696                    "tags": m.metadata.tags,
697                    "created_at": m.metadata.created_at.to_rfc3339(),
698                })
699            })
700            .collect();
701        Ok(json!({
702            "status": "success",
703            "session_id": session_id,
704            "count": filtered.len(),
705            "memories": filtered,
706        }))
707    }
708    fn stats(&self) -> &ToolStats {
709        &self.stats
710    }
711}
712
713/// `session.end` — end a session.
714pub struct SessionEndTool {
715    store: Arc<MemoryStore>,
716    stats: ToolStats,
717    effects: EffectRow,
718    search: Option<Arc<wm_memory::SearchEngine>>,
719}
720
721impl SessionEndTool {
722    pub fn new(store: Arc<MemoryStore>) -> Self {
723        Self {
724            store,
725            stats: ToolStats::default(),
726            effects: EffectRow {
727                writes: vec![Resource::Galaxy("sessions".into())],
728                ..Default::default()
729            },
730            search: None,
731        }
732    }
733
734    /// Index writes at write time so `wm status` index health and
735    /// `memory.search` agree with canonical storage without waiting for the
736    /// next startup heal (2026-09-15 review finding).
737    #[must_use]
738    pub fn with_search(mut self, search: Option<Arc<wm_memory::SearchEngine>>) -> Self {
739        self.search = search;
740        self
741    }
742}
743
744#[async_trait]
745impl Tool for SessionEndTool {
746    fn name(&self) -> &str {
747        "session.end"
748    }
749    fn gana(&self) -> Gana {
750        Gana::StraddlingLegs
751    }
752    fn effects(&self) -> &EffectRow {
753        &self.effects
754    }
755    fn description(&self) -> &str {
756        "End a session — writes a session_end marker"
757    }
758    fn input_schema(&self) -> Value {
759        super::common::schema(
760            &json!({
761                "session_id": super::common::str_prop("Session UUID to end (start-memory id)"),
762                "summary": super::common::str_prop("Optional closing summary"),
763            }),
764            &["session_id"],
765        )
766    }
767    async fn call(&self, _ctx: &mut Context, args: Value) -> wm_core::Result<Value> {
768        let session_id = args
769            .get("session_id")
770            .and_then(|v| v.as_str())
771            .unwrap_or("");
772        let summary = args.get("summary").and_then(|v| v.as_str()).unwrap_or("");
773        let mut mem = Memory::new(
774            Galaxy::Sessions,
775            json!({
776                "type": "session_end",
777                "session_id": session_id,
778                "summary": summary,
779            })
780            .to_string(),
781        );
782        mem.metadata.tags = vec!["session".into(), "end".into()];
783        mem.metadata.importance = 0.6;
784        // Machine-captured event — claims system provenance, never user.
785        mem.metadata.source = "system".to_string();
786        mem.metadata.source_trust = 0.7;
787        self.store.put(Galaxy::Sessions, &mem)?;
788        super::common::index_memory(self.search.as_deref(), &mem);
789        let episodic_capture_error = crate::capture_explicit_memory(
790            &self.store,
791            &mem,
792            EpisodicKind::SystemEvent,
793            ProvenanceSource::System,
794            uuid::Uuid::parse_str(session_id).ok(),
795            0,
796        );
797        let mut response = json!({
798            "status": "success",
799            "session_id": session_id,
800            "end_id": mem.metadata.id,
801        });
802        if let Some(error) = episodic_capture_error {
803            response["warnings"] = json!([format!(
804                "episodic capture failed after the session event was stored: {error}"
805            )]);
806        }
807        Ok(response)
808    }
809    fn stats(&self) -> &ToolStats {
810        &self.stats
811    }
812}
813
814#[cfg(test)]
815mod tests {
816    use super::*;
817
818    fn test_store() -> Arc<MemoryStore> {
819        let dir = tempfile::tempdir().unwrap();
820        let path = dir.path().join("lmdb");
821        std::fs::create_dir_all(&path).unwrap();
822        Arc::new(MemoryStore::open_default(path).unwrap())
823    }
824
825    fn start_session(store: &MemoryStore) -> String {
826        let mut mem = Memory::new(
827            Galaxy::Sessions,
828            json!({"type": "session_start"}).to_string(),
829        );
830        mem.metadata.tags = vec!["session".into(), "start".into()];
831        store.put(Galaxy::Sessions, &mem).unwrap();
832        mem.metadata.id.to_string()
833    }
834
835    /// Fresh local git repository with one empty initial commit — returns
836    /// its path.
837    fn git_repo() -> (tempfile::TempDir, PathBuf) {
838        let dir = tempfile::tempdir().unwrap();
839        let root = dir.path().to_path_buf();
840        let run = |args: &[&str]| {
841            std::process::Command::new("git")
842                .args(args)
843                .current_dir(&root)
844                .output()
845                .expect("git must be available")
846        };
847        assert!(run(&["init", "-q"]).status.success());
848        assert!(run(&["config", "user.email", "t@t"]).status.success());
849        assert!(run(&["config", "user.name", "t"]).status.success());
850        assert!(
851            run(&["commit", "--allow-empty", "-m", "c1"])
852                .status
853                .success()
854        );
855        (dir, root)
856    }
857
858    /// Machine events claim system provenance (never user) — the
859    /// sessions-galaxy attribution fix (2026-08-29).
860    #[tokio::test]
861    async fn start_marker_stamps_system_provenance() {
862        let store = test_store();
863        let tool = SessionStartTool::new(store.clone());
864        let mut ctx = Context::default();
865        let out = tool
866            .call(&mut ctx, json!({"title": "prov test"}))
867            .await
868            .unwrap();
869        let sid = uuid::Uuid::parse_str(out["session_id"].as_str().unwrap()).unwrap();
870        let mem = store
871            .get(Galaxy::Sessions, sid)
872            .expect("start stored")
873            .expect("start present");
874        assert_eq!(mem.metadata.source, "system");
875        assert!((mem.metadata.source_trust - 0.7).abs() < 1e-5);
876    }
877
878    #[tokio::test]
879    async fn checkpoint_auto_captures_git_state() {
880        let store = test_store();
881        let sid = start_session(&store);
882        let (_guard, root) = git_repo();
883
884        let tool = SessionCheckpointTool::new(store.clone());
885        let mut ctx = Context::default();
886        let r = tool
887            .call(
888                &mut ctx,
889                json!({"session_id": sid, "root": root.display().to_string(), "tests_green": true}),
890            )
891            .await
892            .unwrap();
893
894        assert_eq!(r["status"], "success");
895        let git = &r["handoff"]["git"];
896        let expected = String::from_utf8(
897            std::process::Command::new("git")
898                .args(["rev-parse", "HEAD"])
899                .current_dir(&root)
900                .output()
901                .unwrap()
902                .stdout,
903        )
904        .unwrap();
905        assert_eq!(
906            git["commit"].as_str().unwrap().trim(),
907            expected.trim(),
908            "checkpoint must auto-capture the live HEAD"
909        );
910        assert_eq!(git["dirty_count"], 0);
911        assert_eq!(r["handoff"]["tests_green"], true);
912    }
913
914    #[tokio::test]
915    async fn checkpoint_resolves_latest_session_when_absent() {
916        let store = test_store();
917        let _old = start_session(&store);
918        let newest = start_session(&store);
919
920        let tool = SessionCheckpointTool::new(store);
921        let mut ctx = Context::default();
922        let r = tool.call(&mut ctx, json!({"label": "wrap"})).await.unwrap();
923
924        assert_eq!(r["status"], "success");
925        assert_eq!(r["session_id"], newest, "must target the newest session");
926    }
927
928    #[tokio::test]
929    async fn verify_reports_clean_then_drifted() {
930        let store = test_store();
931        let sid = start_session(&store);
932        let (dir_guard, root) = git_repo();
933        let root_str = root.display().to_string();
934
935        let cp = SessionCheckpointTool::new(store.clone());
936        let mut ctx = Context::default();
937        cp.call(&mut ctx, json!({"session_id": sid, "root": root_str}))
938            .await
939            .unwrap();
940
941        let verify = SessionVerifyTool::new(store.clone());
942        let clean = verify
943            .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
944            .await
945            .unwrap();
946        assert_eq!(clean["verifiable"], true);
947        assert_eq!(clean["verdict"], "clean", "got: {clean}");
948        assert_eq!(clean["commits_ahead"], 0);
949
950        // Land a second commit behind the checkpoint's back.
951        assert!(
952            std::process::Command::new("git")
953                .args(["commit", "--allow-empty", "-m", "c2"])
954                .current_dir(&root)
955                .output()
956                .unwrap()
957                .status
958                .success()
959        );
960
961        let drifted = verify
962            .call(&mut ctx, json!({"session_id": sid, "root": root_str}))
963            .await
964            .unwrap();
965        assert_eq!(drifted["verdict"], "drifted", "got: {drifted}");
966        assert_eq!(drifted["commits_ahead"], 1);
967        assert_ne!(
968            drifted["stored_git"]["commit"],
969            drifted["current_git"]["commit"]
970        );
971
972        drop(dir_guard);
973    }
974
975    #[tokio::test]
976    async fn checkpoint_carries_lease_id_in_handoff() {
977        let store = test_store();
978        let sid = start_session(&store);
979
980        let tool = SessionCheckpointTool::new(store);
981        let mut ctx = Context::default();
982        let r = tool
983            .call(
984                &mut ctx,
985                json!({"session_id": sid, "lease_id": "src/expansion/"}),
986            )
987            .await
988            .unwrap();
989
990        assert_eq!(r["status"], "success");
991        assert_eq!(r["handoff"]["lease_id"], "src/expansion/");
992    }
993
994    #[tokio::test]
995    async fn nodiscovery_checkpoint_stores_exactly_the_supplied_fields() {
996        let store = test_store();
997        let sid = start_session(&store);
998
999        // Effect shape: no reads, no spawns — the strict-mode-admitted form.
1000        let tool = SessionCheckpointNodiscoveryTool::new(store.clone());
1001        assert!(tool.effects().reads.is_empty());
1002        assert!(!tool.effects().spawns);
1003        assert!(tool.effects().is_no_discovery_checkpoint());
1004
1005        // The rich checkpoint truthfully declares its git reads/spawns.
1006        let rich = SessionCheckpointTool::new(store.clone());
1007        assert!(rich.effects().spawns, "git capture must declare its spawns");
1008        assert!(rich.effects().reads.contains(&Resource::Filesystem));
1009        assert!(
1010            rich.effects().reads.contains(&Resource::Process),
1011            "spawning tools must declare the Process resource (effect audit)"
1012        );
1013
1014        let mut ctx = Context::default();
1015        let r = tool
1016            .call(
1017                &mut ctx,
1018                json!({
1019                    "session_id": sid,
1020                    "label": "stress",
1021                    "commit": "abc1234",
1022                    "branch": "main",
1023                    "tests_green": true,
1024                    "next_queue": ["finish A"],
1025                    "open_flags": ["gate"],
1026                    "lease_id": "scope/x"
1027                }),
1028            )
1029            .await
1030            .unwrap();
1031
1032        assert_eq!(r["status"], "success");
1033        assert_eq!(r["handoff"]["commit"], "abc1234");
1034        assert_eq!(r["handoff"]["branch"], "main");
1035        assert_eq!(r["handoff"]["tests_green"], true);
1036        assert_eq!(r["handoff"]["next_queue"][0], "finish A");
1037        assert_eq!(r["handoff"]["open_flags"][0], "gate");
1038        assert_eq!(r["handoff"]["lease_id"], "scope/x");
1039        assert!(
1040            r["handoff"].get("git").is_none(),
1041            "no-discovery checkpoint must never contain captured git state: {r}"
1042        );
1043    }
1044
1045    #[tokio::test]
1046    async fn verify_reports_unverifiable_without_git_checkpoint() {
1047        let store = test_store();
1048        let sid = start_session(&store);
1049
1050        // Legacy-style checkpoint: data passthrough only, no handoff.git.
1051        let cp = SessionCheckpointTool::new(store.clone());
1052        let mut ctx = Context::default();
1053        // NOTE: no `root` arg and WM_PROJECT_ROOT unset in the test env.
1054        let r = cp.call(&mut ctx, json!({"session_id": sid})).await.unwrap();
1055        assert!(r["handoff"]["git"].is_null());
1056
1057        let verify = SessionVerifyTool::new(store);
1058        let v = verify
1059            .call(&mut ctx, json!({"session_id": sid}))
1060            .await
1061            .unwrap();
1062        assert_eq!(v["verifiable"], false, "got: {v}");
1063        assert!(v["message"].as_str().unwrap().contains("no checkpoint"));
1064    }
1065}