Skip to main content

bamboo_memory/
plan_store.rs

1//! Plan artifact persistence for plan mode sessions.
2//!
3//! Stores plan artifacts in `${BAMBOO_DATA_DIR}/plan/{session_slug}/`:
4//! - `plan.md` — human-readable plan markdown
5//! - `state.json` — machine-readable plan runtime state
6//! - `cursor.json` — machine-readable execution/resume cursor
7//! - `sections.json` — structured plan section index for precise resume
8//!
9//! For backward compatibility, reads also fall back to the legacy flat file
10//! layout `${BAMBOO_DATA_DIR}/plan/{session_slug}.md` when present.
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use std::fs::{self, File, OpenOptions};
15use std::io::Write as _;
16use std::path::{Path, PathBuf};
17use std::time::{SystemTime, UNIX_EPOCH};
18
19const PLAN_FILE_NAME: &str = "plan.md";
20const PLAN_STATE_FILE_NAME: &str = "state.json";
21const PLAN_CURSOR_FILE_NAME: &str = "cursor.json";
22const PLAN_SECTIONS_FILE_NAME: &str = "sections.json";
23const PLAN_ARTIFACT_VERSION: u32 = 1;
24
25/// Error type for plan store operations.
26#[derive(Debug, thiserror::Error)]
27pub enum PlanStoreError {
28    #[error("IO error: {0}")]
29    Io(#[from] std::io::Error),
30    #[error("JSON error: {0}")]
31    Json(#[from] serde_json::Error),
32    #[error("Plan directory not accessible: {0}")]
33    DirectoryNotAccessible(String),
34}
35
36/// Durable machine-readable state for a plan mode session.
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
38pub struct PlanStateArtifact {
39    pub version: u32,
40    pub session_id: String,
41    pub updated_at: DateTime<Utc>,
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub status: Option<String>,
44    #[serde(default, skip_serializing_if = "Option::is_none")]
45    pub active_task_id: Option<String>,
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub active_step_id: Option<String>,
48    #[serde(default, skip_serializing_if = "Option::is_none")]
49    pub next_step_id: Option<String>,
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub active_section_id: Option<String>,
52    #[serde(default, skip_serializing_if = "Option::is_none")]
53    pub next_section_id: Option<String>,
54    #[serde(default, skip_serializing_if = "Option::is_none")]
55    pub last_completed_task_id: Option<String>,
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub last_completed_section_id: Option<String>,
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub round_hint: Option<u32>,
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub plan_hash: Option<String>,
62}
63
64impl PlanStateArtifact {
65    pub fn new(session_id: impl Into<String>) -> Self {
66        Self {
67            version: PLAN_ARTIFACT_VERSION,
68            session_id: session_id.into(),
69            updated_at: Utc::now(),
70            status: None,
71            active_task_id: None,
72            active_step_id: None,
73            next_step_id: None,
74            active_section_id: None,
75            next_section_id: None,
76            last_completed_task_id: None,
77            last_completed_section_id: None,
78            round_hint: None,
79            plan_hash: None,
80        }
81    }
82}
83
84/// Durable machine-readable execution cursor for resuming plan execution.
85#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
86pub struct PlanCursorArtifact {
87    pub version: u32,
88    pub session_id: String,
89    pub updated_at: DateTime<Utc>,
90    #[serde(default, skip_serializing_if = "Option::is_none")]
91    pub cursor_type: Option<String>,
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub current_task_id: Option<String>,
94    #[serde(default, skip_serializing_if = "Option::is_none")]
95    pub current_task_ordinal: Option<u32>,
96    #[serde(default, skip_serializing_if = "Option::is_none")]
97    pub current_step_id: Option<String>,
98    #[serde(default, skip_serializing_if = "Option::is_none")]
99    pub current_section_id: Option<String>,
100    #[serde(default, skip_serializing_if = "Option::is_none")]
101    pub next_task_id: Option<String>,
102    #[serde(default, skip_serializing_if = "Option::is_none")]
103    pub next_task_ordinal: Option<u32>,
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub next_section_id: Option<String>,
106    #[serde(default, skip_serializing_if = "Option::is_none")]
107    pub last_completed_task_id: Option<String>,
108    #[serde(default, skip_serializing_if = "Option::is_none")]
109    pub last_completed_section_id: Option<String>,
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub last_completed_checkpoint: Option<String>,
112    #[serde(default, skip_serializing_if = "Option::is_none")]
113    pub round_hint: Option<u32>,
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub round_id_hint: Option<String>,
116    #[serde(default, skip_serializing_if = "Option::is_none")]
117    pub suspension_hook_point: Option<String>,
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub tool_call_boundary: Option<String>,
120    #[serde(default, skip_serializing_if = "Option::is_none")]
121    pub resume_note: Option<String>,
122}
123
124impl PlanCursorArtifact {
125    pub fn new(session_id: impl Into<String>) -> Self {
126        Self {
127            version: PLAN_ARTIFACT_VERSION,
128            session_id: session_id.into(),
129            updated_at: Utc::now(),
130            cursor_type: None,
131            current_task_id: None,
132            current_task_ordinal: None,
133            current_step_id: None,
134            current_section_id: None,
135            next_task_id: None,
136            next_task_ordinal: None,
137            next_section_id: None,
138            last_completed_task_id: None,
139            last_completed_section_id: None,
140            last_completed_checkpoint: None,
141            round_hint: None,
142            round_id_hint: None,
143            suspension_hook_point: None,
144            tool_call_boundary: None,
145            resume_note: None,
146        }
147    }
148}
149
150/// Indexed section metadata extracted from persisted plan markdown.
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
152pub struct PlanSectionArtifact {
153    pub version: u32,
154    pub session_id: String,
155    pub updated_at: DateTime<Utc>,
156    pub sections: Vec<PlanSection>,
157}
158
159impl PlanSectionArtifact {
160    pub fn new(session_id: impl Into<String>, sections: Vec<PlanSection>) -> Self {
161        Self {
162            version: PLAN_ARTIFACT_VERSION,
163            session_id: session_id.into(),
164            updated_at: Utc::now(),
165            sections,
166        }
167    }
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
171pub struct PlanSection {
172    pub id: String,
173    pub heading: String,
174    pub level: u8,
175    pub line_start: usize,
176    pub line_end: usize,
177    #[serde(default, skip_serializing_if = "Option::is_none")]
178    pub parent_id: Option<String>,
179    #[serde(default, skip_serializing_if = "Vec::is_empty")]
180    pub anchor_terms: Vec<String>,
181}
182
183/// Stores and retrieves plan artifacts for sessions.
184#[derive(Debug, Clone)]
185pub struct PlanStore {
186    plans_dir: PathBuf,
187}
188
189impl PlanStore {
190    /// Create a new plan store with the given base data directory.
191    ///
192    /// Artifacts are stored in `{data_dir}/plan/`.
193    pub fn new(data_dir: impl AsRef<Path>) -> Result<Self, PlanStoreError> {
194        let plans_dir = data_dir.as_ref().join("plan");
195        fs::create_dir_all(&plans_dir).map_err(|e| {
196            PlanStoreError::DirectoryNotAccessible(format!(
197                "Failed to create plan directory at {}: {}",
198                plans_dir.display(),
199                e
200            ))
201        })?;
202        Ok(Self { plans_dir })
203    }
204
205    /// Generate a slug from a session ID suitable for use as a filename.
206    ///
207    /// Uses the first 8 characters of the session ID plus a hash of the remainder
208    /// to produce a short, deterministic, filesystem-safe identifier.
209    fn session_slug(session_id: &str) -> String {
210        let clean = session_id
211            .chars()
212            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
213            .collect::<String>();
214
215        if clean.len() <= 16 {
216            return clean;
217        }
218
219        let prefix: String = clean.chars().take(8).collect();
220        let suffix: String = clean
221            .chars()
222            .rev()
223            .take(8)
224            .collect::<Vec<_>>()
225            .into_iter()
226            .rev()
227            .collect();
228        format!(
229            "{}-{}-{:x}",
230            prefix,
231            suffix,
232            seahash::hash(clean.as_bytes())
233        )
234    }
235
236    fn session_dir_path_internal(&self, session_id: &str) -> PathBuf {
237        self.plans_dir.join(Self::session_slug(session_id))
238    }
239
240    fn preferred_plan_file_path(&self, session_id: &str) -> PathBuf {
241        self.session_dir_path_internal(session_id).join(PLAN_FILE_NAME)
242    }
243
244    fn legacy_plan_file_path(&self, session_id: &str) -> PathBuf {
245        self.plans_dir
246            .join(format!("{}.md", Self::session_slug(session_id)))
247    }
248
249    fn resolved_plan_file_path_internal(&self, session_id: &str) -> PathBuf {
250        let preferred = self.preferred_plan_file_path(session_id);
251        if preferred.exists() {
252            preferred
253        } else {
254            let legacy = self.legacy_plan_file_path(session_id);
255            if legacy.exists() {
256                legacy
257            } else {
258                preferred
259            }
260        }
261    }
262
263    fn ensure_session_dir(&self, session_id: &str) -> Result<PathBuf, PlanStoreError> {
264        let dir = self.session_dir_path_internal(session_id);
265        fs::create_dir_all(&dir)?;
266        Ok(dir)
267    }
268
269    fn unique_temp_path(path: &Path) -> PathBuf {
270        let nanos = SystemTime::now()
271            .duration_since(UNIX_EPOCH)
272            .map(|duration| duration.as_nanos())
273            .unwrap_or(0);
274        let file_name = path
275            .file_name()
276            .and_then(|name| name.to_str())
277            .unwrap_or("artifact");
278        path.with_file_name(format!(".{file_name}.{nanos}.{}.tmp", std::process::id()))
279    }
280
281    fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<(), PlanStoreError> {
282        if let Some(parent) = path.parent() {
283            fs::create_dir_all(parent)?;
284        }
285
286        let temp_path = Self::unique_temp_path(path);
287        let mut file = OpenOptions::new()
288            .create(true)
289            .truncate(true)
290            .write(true)
291            .open(&temp_path)?;
292        file.write_all(bytes)?;
293        file.flush()?;
294        file.sync_all()?;
295        drop(file);
296
297        fs::rename(&temp_path, path)?;
298
299        if let Some(parent) = path.parent() {
300            if let Ok(dir) = File::open(parent) {
301                let _ = dir.sync_all();
302            }
303        }
304
305        Ok(())
306    }
307
308    fn atomic_write_json<T: Serialize>(path: &Path, value: &T) -> Result<(), PlanStoreError> {
309        let bytes = serde_json::to_vec_pretty(value)?;
310        Self::atomic_write_bytes(path, &bytes)
311    }
312
313    fn read_json_artifact<T: for<'de> Deserialize<'de>>(
314        &self,
315        path: &Path,
316    ) -> Result<Option<T>, PlanStoreError> {
317        if !path.exists() {
318            return Ok(None);
319        }
320        let raw = fs::read_to_string(path)?;
321        Ok(Some(serde_json::from_str(&raw)?))
322    }
323
324    fn normalize_section_token(token: &str) -> String {
325        let mut normalized = String::new();
326        let mut last_was_dash = false;
327
328        for ch in token.chars() {
329            if ch.is_ascii_alphanumeric() {
330                normalized.push(ch.to_ascii_lowercase());
331                last_was_dash = false;
332            } else if (ch.is_whitespace() || matches!(ch, '-' | '_' | ':' | '.')) && !last_was_dash {
333                normalized.push('-');
334                last_was_dash = true;
335            }
336        }
337
338        normalized = normalized.trim_matches('-').to_string();
339        if normalized.is_empty() {
340            "section".to_string()
341        } else {
342            normalized
343        }
344    }
345
346    fn extract_inline_anchor_terms(line: &str, heading: &str) -> Vec<String> {
347        let mut anchors = Vec::new();
348        let heading_trimmed = heading.trim();
349        if !heading_trimmed.is_empty() {
350            anchors.push(heading_trimmed.to_string());
351        }
352
353        let trimmed = line.trim();
354        if let Some((key, value)) = trimmed.split_once(':') {
355            let key = key.trim();
356            let value = value.trim();
357            if matches!(key, "- task_id" | "task_id" | "- current_step_id" | "current_step_id" | "- step_id" | "step_id") {
358                if !value.is_empty() {
359                    anchors.push(value.to_string());
360                }
361            }
362        }
363
364        anchors.sort();
365        anchors.dedup();
366        anchors
367    }
368
369    fn heading_level_and_title(line: &str) -> Option<(u8, String)> {
370        let trimmed = line.trim_start();
371        let hashes = trimmed.chars().take_while(|ch| *ch == '#').count();
372        if hashes == 0 {
373            return None;
374        }
375        let title = trimmed[hashes..].trim();
376        if title.is_empty() {
377            return None;
378        }
379        Some((hashes as u8, title.to_string()))
380    }
381
382    fn index_plan_sections(session_id: &str, content: &str) -> PlanSectionArtifact {
383        let lines: Vec<&str> = content.lines().collect();
384        let mut sections = Vec::new();
385        let mut heading_indices = Vec::new();
386
387        for (index, line) in lines.iter().enumerate() {
388            if let Some((level, heading)) = Self::heading_level_and_title(line) {
389                heading_indices.push((index, level, heading));
390            }
391        }
392
393        for (position, (line_start, level, heading)) in heading_indices.iter().enumerate() {
394            let line_end = heading_indices
395                .get(position + 1)
396                .map(|(next_start, _, _)| next_start.saturating_sub(1))
397                .unwrap_or_else(|| lines.len().saturating_sub(1));
398
399            let parent_id = heading_indices[..position]
400                .iter()
401                .rev()
402                .find(|(_, candidate_level, _)| *candidate_level < *level)
403                .map(|(_, _, candidate_heading)| Self::normalize_section_token(candidate_heading));
404
405            let mut anchor_terms = vec![heading.clone()];
406            for line in lines[*line_start..=line_end].iter().take(10) {
407                anchor_terms.extend(Self::extract_inline_anchor_terms(line, heading));
408            }
409            anchor_terms.sort();
410            anchor_terms.dedup();
411
412            sections.push(PlanSection {
413                id: Self::normalize_section_token(heading),
414                heading: heading.clone(),
415                level: *level,
416                line_start: *line_start,
417                line_end,
418                parent_id,
419                anchor_terms,
420            });
421        }
422
423        PlanSectionArtifact::new(session_id, sections)
424    }
425
426    /// Path to the session artifact directory for a given session.
427    pub fn session_dir_path(&self, session_id: &str) -> PathBuf {
428        self.session_dir_path_internal(session_id)
429    }
430
431    /// Path to the plan markdown file for a given session.
432    ///
433    /// Returns the currently resolved path. If a legacy flat-file artifact exists and
434    /// the new directory-based artifact does not, this returns the legacy path.
435    pub fn plan_file_path(&self, session_id: &str) -> PathBuf {
436        self.resolved_plan_file_path_internal(session_id)
437    }
438
439    /// Path to the plan machine state artifact for a given session.
440    pub fn state_file_path(&self, session_id: &str) -> PathBuf {
441        self.session_dir_path_internal(session_id)
442            .join(PLAN_STATE_FILE_NAME)
443    }
444
445    /// Path to the plan cursor artifact for a given session.
446    pub fn cursor_file_path(&self, session_id: &str) -> PathBuf {
447        self.session_dir_path_internal(session_id)
448            .join(PLAN_CURSOR_FILE_NAME)
449    }
450
451    /// Path to the indexed sections artifact for a given session.
452    pub fn sections_file_path(&self, session_id: &str) -> PathBuf {
453        self.session_dir_path_internal(session_id)
454            .join(PLAN_SECTIONS_FILE_NAME)
455    }
456
457    /// Write a plan markdown artifact for the given session.
458    ///
459    /// Overwrites any existing directory-based artifact for this session and refreshes
460    /// the section index artifact derived from the markdown.
461    pub fn write_plan(
462        &self,
463        session_id: &str,
464        content: impl AsRef<str>,
465    ) -> Result<PathBuf, PlanStoreError> {
466        self.ensure_session_dir(session_id)?;
467        let content = content.as_ref();
468        let path = self.preferred_plan_file_path(session_id);
469        Self::atomic_write_bytes(&path, content.as_bytes())?;
470
471        let sections = Self::index_plan_sections(session_id, content);
472        let sections_path = self.sections_file_path(session_id);
473        Self::atomic_write_json(&sections_path, &sections)?;
474
475        let legacy_path = self.legacy_plan_file_path(session_id);
476        if legacy_path.exists() {
477            let _ = fs::remove_file(legacy_path);
478        }
479
480        Ok(path)
481    }
482
483    /// Read the plan markdown artifact for the given session, if it exists.
484    pub fn read_plan(&self, session_id: &str) -> Option<String> {
485        let path = self.resolved_plan_file_path_internal(session_id);
486        fs::read_to_string(&path).ok()
487    }
488
489    /// Check whether a plan artifact exists for the given session.
490    pub fn plan_exists(&self, session_id: &str) -> bool {
491        self.resolved_plan_file_path_internal(session_id).exists()
492    }
493
494    /// Write the plan machine state artifact for the given session.
495    pub fn write_state(
496        &self,
497        session_id: &str,
498        state: &PlanStateArtifact,
499    ) -> Result<PathBuf, PlanStoreError> {
500        self.ensure_session_dir(session_id)?;
501        let path = self.state_file_path(session_id);
502        Self::atomic_write_json(&path, state)?;
503        Ok(path)
504    }
505
506    /// Read the plan machine state artifact for the given session, if it exists.
507    pub fn read_state(&self, session_id: &str) -> Result<Option<PlanStateArtifact>, PlanStoreError> {
508        self.read_json_artifact(&self.state_file_path(session_id))
509    }
510
511    /// Write the plan cursor artifact for the given session.
512    pub fn write_cursor(
513        &self,
514        session_id: &str,
515        cursor: &PlanCursorArtifact,
516    ) -> Result<PathBuf, PlanStoreError> {
517        self.ensure_session_dir(session_id)?;
518        let path = self.cursor_file_path(session_id);
519        Self::atomic_write_json(&path, cursor)?;
520        Ok(path)
521    }
522
523    /// Read the plan cursor artifact for the given session, if it exists.
524    pub fn read_cursor(
525        &self,
526        session_id: &str,
527    ) -> Result<Option<PlanCursorArtifact>, PlanStoreError> {
528        self.read_json_artifact(&self.cursor_file_path(session_id))
529    }
530
531    /// Read the indexed plan sections artifact for the given session, if it exists.
532    pub fn read_sections(
533        &self,
534        session_id: &str,
535    ) -> Result<Option<PlanSectionArtifact>, PlanStoreError> {
536        self.read_json_artifact(&self.sections_file_path(session_id))
537    }
538
539    /// Delete all plan artifacts for the given session.
540    pub fn delete_plan(&self, session_id: &str) -> Result<(), PlanStoreError> {
541        let legacy_path = self.legacy_plan_file_path(session_id);
542        if legacy_path.exists() {
543            fs::remove_file(&legacy_path)?;
544        }
545
546        let session_dir = self.session_dir_path_internal(session_id);
547        if session_dir.exists() {
548            fs::remove_dir_all(session_dir)?;
549        }
550
551        Ok(())
552    }
553
554    /// Get the root storage directory path.
555    pub fn plans_dir(&self) -> &Path {
556        &self.plans_dir
557    }
558}
559
560#[cfg(test)]
561mod tests {
562    use super::*;
563
564    fn temp_store() -> (tempfile::TempDir, PlanStore) {
565        let temp_dir = tempfile::tempdir().unwrap();
566        let store = PlanStore::new(temp_dir.path()).unwrap();
567        (temp_dir, store)
568    }
569
570    #[test]
571    fn session_slug_produces_short_identifier() {
572        let id = "sess-abc123-def456-ghi789";
573        let slug = PlanStore::session_slug(id);
574        assert!(!slug.is_empty());
575        assert!(!slug.contains('/'));
576        assert!(!slug.contains('\\'));
577    }
578
579    #[test]
580    fn write_and_read_plan() {
581        let (_tmp, store) = temp_store();
582        let session_id = "test-session-001";
583        let content = "# Implementation Plan\n\n1. Step one\n2. Step two\n";
584
585        let path = store.write_plan(session_id, content).unwrap();
586        assert!(path.exists());
587        assert!(store.plan_exists(session_id));
588
589        let read = store.read_plan(session_id).unwrap();
590        assert_eq!(read, content);
591    }
592
593    #[test]
594    fn read_nonexistent_plan_returns_none() {
595        let (_tmp, store) = temp_store();
596        assert!(store.read_plan("nonexistent-session").is_none());
597        assert!(!store.plan_exists("nonexistent-session"));
598    }
599
600    #[test]
601    fn write_plan_overwrites_existing() {
602        let (_tmp, store) = temp_store();
603        let session_id = "test-session-002";
604
605        store.write_plan(session_id, "Plan v1").unwrap();
606        store.write_plan(session_id, "Plan v2").unwrap();
607
608        let read = store.read_plan(session_id).unwrap();
609        assert_eq!(read, "Plan v2");
610    }
611
612    #[test]
613    fn delete_plan_removes_artifact_directory() {
614        let (_tmp, store) = temp_store();
615        let session_id = "test-session-003";
616
617        store.write_plan(session_id, "Plan to delete").unwrap();
618        store
619            .write_state(session_id, &PlanStateArtifact::new(session_id))
620            .unwrap();
621        store
622            .write_cursor(session_id, &PlanCursorArtifact::new(session_id))
623            .unwrap();
624        assert!(store.plan_exists(session_id));
625        assert!(store.session_dir_path(session_id).exists());
626
627        store.delete_plan(session_id).unwrap();
628        assert!(!store.plan_exists(session_id));
629        assert!(!store.session_dir_path(session_id).exists());
630    }
631
632    #[test]
633    fn delete_nonexistent_plan_is_noop() {
634        let (_tmp, store) = temp_store();
635        store.delete_plan("never-created").unwrap();
636    }
637
638    #[test]
639    fn plan_file_path_is_under_session_artifact_dir() {
640        let (_tmp, store) = temp_store();
641        let path = store.plan_file_path("some-session");
642        assert!(path.starts_with(&store.plans_dir));
643        assert_eq!(path.file_name().and_then(|n| n.to_str()), Some(PLAN_FILE_NAME));
644        assert_eq!(path.parent().unwrap().parent().unwrap(), store.plans_dir());
645    }
646
647    #[test]
648    fn session_slug_handles_short_id() {
649        let id = "short";
650        let slug = PlanStore::session_slug(id);
651        assert_eq!(slug, "short");
652    }
653
654    #[test]
655    fn session_slug_strips_special_chars() {
656        let id = "sess/abc\\def:ghi";
657        let slug = PlanStore::session_slug(id);
658        assert!(!slug.contains('/'));
659        assert!(!slug.contains('\\'));
660        assert!(!slug.contains(':'));
661    }
662
663    #[test]
664    fn write_and_read_state_artifact() {
665        let (_tmp, store) = temp_store();
666        let session_id = "state-session-1";
667        let mut state = PlanStateArtifact::new(session_id);
668        state.status = Some("awaiting_approval".to_string());
669        state.active_task_id = Some("task-1".to_string());
670        state.active_section_id = Some("task-1".to_string());
671        state.last_completed_task_id = Some("task-0".to_string());
672        state.round_hint = Some(3);
673
674        let path = store.write_state(session_id, &state).unwrap();
675        assert!(path.exists());
676
677        let read = store.read_state(session_id).unwrap().unwrap();
678        assert_eq!(read, state);
679    }
680
681    #[test]
682    fn write_and_read_cursor_artifact() {
683        let (_tmp, store) = temp_store();
684        let session_id = "cursor-session-1";
685        let mut cursor = PlanCursorArtifact::new(session_id);
686        cursor.cursor_type = Some("task_item".to_string());
687        cursor.current_task_id = Some("task-2".to_string());
688        cursor.current_task_ordinal = Some(2);
689        cursor.current_section_id = Some("task-2".to_string());
690        cursor.next_task_id = Some("task-3".to_string());
691        cursor.next_task_ordinal = Some(3);
692        cursor.last_completed_task_id = Some("task-1".to_string());
693        cursor.round_hint = Some(4);
694        cursor.round_id_hint = Some("round-4".to_string());
695        cursor.suspension_hook_point = Some("AfterToolExecution".to_string());
696        cursor.tool_call_boundary = Some("ExitPlanMode".to_string());
697        cursor.resume_note = Some("Continue from task-2".to_string());
698
699        let path = store.write_cursor(session_id, &cursor).unwrap();
700        assert!(path.exists());
701
702        let read = store.read_cursor(session_id).unwrap().unwrap();
703        assert_eq!(read, cursor);
704    }
705
706    #[test]
707    fn read_plan_falls_back_to_legacy_flat_file() {
708        let (_tmp, store) = temp_store();
709        let session_id = "legacy-session-1";
710        let legacy_path = store.legacy_plan_file_path(session_id);
711        fs::write(&legacy_path, "Legacy plan").unwrap();
712
713        assert_eq!(store.read_plan(session_id).as_deref(), Some("Legacy plan"));
714        assert_eq!(store.plan_file_path(session_id), legacy_path);
715    }
716
717    #[test]
718    fn machine_state_writes_do_not_leave_temp_files_behind() {
719        let (_tmp, store) = temp_store();
720        let session_id = "temp-cleanup-session";
721        let mut state = PlanStateArtifact::new(session_id);
722        state.status = Some("designing".to_string());
723        store.write_state(session_id, &state).unwrap();
724
725        let entries = fs::read_dir(store.session_dir_path(session_id))
726            .unwrap()
727            .map(|entry| entry.unwrap().file_name().to_string_lossy().to_string())
728            .collect::<Vec<_>>();
729        assert!(entries.iter().all(|name| !name.ends_with(".tmp")));
730        assert!(entries.iter().any(|name| name == PLAN_STATE_FILE_NAME));
731    }
732
733    #[test]
734    fn write_plan_generates_section_index_artifact() {
735        let (_tmp, store) = temp_store();
736        let session_id = "sectioned-session";
737        let plan = "# Plan\n\n## task-alpha\n- task_id: task-alpha\n- do alpha\n\n### step-alpha-1\n- current_step_id: step-alpha-1\n- detail\n\n## task-bravo\n- task_id: task-bravo\n- do bravo\n";
738
739        store.write_plan(session_id, plan).unwrap();
740        let sections = store
741            .read_sections(session_id)
742            .unwrap()
743            .expect("sections should exist");
744
745        assert!(sections.sections.len() >= 3);
746        let task_alpha = sections
747            .sections
748            .iter()
749            .find(|section| section.id == "task-alpha")
750            .expect("task-alpha section");
751        assert_eq!(task_alpha.heading, "task-alpha");
752        assert!(task_alpha.anchor_terms.iter().any(|term| term == "task-alpha"));
753
754        let step_alpha = sections
755            .sections
756            .iter()
757            .find(|section| section.id == "step-alpha-1")
758            .expect("step-alpha-1 section");
759        assert_eq!(step_alpha.parent_id.as_deref(), Some("task-alpha"));
760        assert!(step_alpha
761            .anchor_terms
762            .iter()
763            .any(|term| term == "step-alpha-1"));
764    }
765}