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