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