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