Skip to main content

atman_runtime/memory/
spec.rs

1use std::path::PathBuf;
2use std::sync::Arc;
3
4use serde::{Deserialize, Serialize};
5
6use super::MemoryId;
7use crate::error::RuntimeError;
8use crate::index::AnchorIndex;
9
10const PHASES: &[&str] = &[
11    "research",
12    "design",
13    "implementation",
14    "testing",
15    "retrospective",
16];
17
18#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
19pub struct SpecEntry {
20    pub id: MemoryId,
21    pub feature: String,
22    pub phase: String,
23    pub content: String,
24    pub ts: chrono::DateTime<chrono::Utc>,
25}
26
27#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
28pub struct SpecDeviation {
29    pub id: MemoryId,
30    pub feature: String,
31    pub section: String,
32    pub delta: String,
33    pub reason: String,
34    pub ts: chrono::DateTime<chrono::Utc>,
35}
36
37#[derive(Clone)]
38pub struct SpecStore {
39    root: PathBuf,
40    anchor_index: Option<Arc<AnchorIndex>>,
41}
42
43impl SpecStore {
44    pub fn new(root: impl Into<PathBuf>) -> Self {
45        Self {
46            root: root.into(),
47            anchor_index: None,
48        }
49    }
50
51    pub fn with_index(mut self, index: Arc<AnchorIndex>) -> Self {
52        self.anchor_index = Some(index);
53        self
54    }
55
56    fn feature_dir(&self, feature: &str) -> PathBuf {
57        self.root.join(feature)
58    }
59
60    fn entries_path(&self, feature: &str) -> PathBuf {
61        self.feature_dir(feature).join("entries.jsonl")
62    }
63
64    fn deviations_path(&self, feature: &str) -> PathBuf {
65        self.feature_dir(feature).join("deviations.jsonl")
66    }
67
68    pub async fn status(&self, feature: &str) -> Result<SpecStatus, RuntimeError> {
69        let entries: Vec<SpecEntry> = super::read_jsonl(&self.entries_path(feature)).await?;
70        if entries.is_empty() {
71            return Ok(SpecStatus {
72                feature: feature.into(),
73                phase: "not_started".into(),
74                entry_count: 0,
75                deviation_count: 0,
76            });
77        }
78        let latest = latest_phase(&entries);
79        let dev_count = super::read_jsonl::<SpecDeviation>(&self.deviations_path(feature))
80            .await?
81            .len();
82        Ok(SpecStatus {
83            feature: feature.into(),
84            phase: latest,
85            entry_count: entries.len(),
86            deviation_count: dev_count,
87        })
88    }
89
90    pub async fn update(
91        &self,
92        feature: &str,
93        phase: &str,
94        content: String,
95    ) -> Result<SpecEntry, RuntimeError> {
96        if !PHASES.contains(&phase) {
97            return Err(RuntimeError::ToolFailed(format!(
98                "spec.update: unknown phase `{phase}` (want one of {})",
99                PHASES.join(", ")
100            )));
101        }
102        let current = self.status(feature).await?;
103        if let Err(msg) = check_phase_transition(&current.phase, phase) {
104            return Err(RuntimeError::ToolFailed(format!("spec.update: {msg}")));
105        }
106        let entry = SpecEntry {
107            id: MemoryId::now(),
108            feature: feature.into(),
109            phase: phase.into(),
110            content,
111            ts: chrono::Utc::now(),
112        };
113        super::append_jsonl(&self.entries_path(feature), &entry).await?;
114        if let Some(idx) = &self.anchor_index
115            && let Err(e) = insert_entry(idx, &entry)
116        {
117            let key = format!("spec.index.entry:{}", entry.id);
118            crate::notify!(
119                warn,
120                location = Inline,
121                stack = dedupe(key, 60_000),
122                "spec entry index insert failed (id={}): {e}",
123                entry.id
124            );
125        }
126        Ok(entry)
127    }
128
129    pub async fn deviate(
130        &self,
131        feature: &str,
132        section: String,
133        delta: String,
134        reason: String,
135    ) -> Result<SpecDeviation, RuntimeError> {
136        let current = self.status(feature).await?;
137        if current.phase == "not_started" {
138            return Err(RuntimeError::ToolFailed(
139                "spec.deviate: feature has no entries yet, run spec.update first".into(),
140            ));
141        }
142        let dev = SpecDeviation {
143            id: MemoryId::now(),
144            feature: feature.into(),
145            section,
146            delta,
147            reason,
148            ts: chrono::Utc::now(),
149        };
150        super::append_jsonl(&self.deviations_path(feature), &dev).await?;
151        if let Some(idx) = &self.anchor_index
152            && let Err(e) = insert_deviation(idx, &dev)
153        {
154            let key = format!("spec.index.deviation:{}", dev.id);
155            crate::notify!(
156                warn,
157                location = Inline,
158                stack = dedupe(key, 60_000),
159                "spec deviation index insert failed (id={}): {e}",
160                dev.id
161            );
162        }
163        Ok(dev)
164    }
165
166    pub async fn deviations(&self, feature: &str) -> Result<Vec<SpecDeviation>, RuntimeError> {
167        super::read_jsonl(&self.deviations_path(feature)).await
168    }
169
170    pub async fn entries(&self, feature: &str) -> Result<Vec<SpecEntry>, RuntimeError> {
171        super::read_jsonl(&self.entries_path(feature)).await
172    }
173}
174
175fn insert_entry(index: &AnchorIndex, entry: &SpecEntry) -> rusqlite::Result<()> {
176    let conn = index.conn();
177    conn.execute(
178        "INSERT OR REPLACE INTO spec_entries (id, feature, phase, content, ts) VALUES (?, ?, ?, ?, ?)",
179        rusqlite::params![
180            entry.id.to_string(),
181            entry.feature,
182            entry.phase,
183            entry.content,
184            entry.ts.to_rfc3339(),
185        ],
186    )?;
187    let rowid = conn.last_insert_rowid();
188    conn.execute(
189        "INSERT OR REPLACE INTO spec_entries_fts (rowid, content) VALUES (?, ?)",
190        rusqlite::params![rowid, entry.content],
191    )?;
192    Ok(())
193}
194
195fn insert_deviation(index: &AnchorIndex, dev: &SpecDeviation) -> rusqlite::Result<()> {
196    let conn = index.conn();
197    conn.execute(
198        "INSERT OR REPLACE INTO spec_deviations (id, feature, section, delta, reason, ts) VALUES (?, ?, ?, ?, ?, ?)",
199        rusqlite::params![
200            dev.id.to_string(),
201            dev.feature,
202            dev.section,
203            dev.delta,
204            dev.reason,
205            dev.ts.to_rfc3339(),
206        ],
207    )?;
208    let rowid = conn.last_insert_rowid();
209    conn.execute(
210        "INSERT OR REPLACE INTO spec_deviations_fts (rowid, delta, reason) VALUES (?, ?, ?)",
211        rusqlite::params![rowid, dev.delta, dev.reason],
212    )?;
213    Ok(())
214}
215
216#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
217pub struct SpecStatus {
218    pub feature: String,
219    pub phase: String,
220    pub entry_count: usize,
221    pub deviation_count: usize,
222}
223
224#[derive(Debug, Clone, PartialEq, Eq)]
225pub struct SpecMaterializeResult {
226    pub path: PathBuf,
227    pub revision: String,
228    pub changed: bool,
229}
230
231impl SpecStore {
232    pub async fn materialize(
233        &self,
234        feature: &str,
235        expected_revision: Option<&str>,
236    ) -> Result<SpecMaterializeResult, RuntimeError> {
237        let entries: Vec<SpecEntry> = super::read_jsonl(&self.entries_path(feature)).await?;
238        let deviations: Vec<SpecDeviation> =
239            super::read_jsonl(&self.deviations_path(feature)).await?;
240        let markdown = render_materialized_markdown(feature, &entries, &deviations);
241        let revision = revision_for(&markdown);
242        let path = self.feature_dir(feature).join("IMPLEMENTATION.md");
243        let existing = tokio::fs::read_to_string(&path).await.ok();
244        if let Some(expected) = expected_revision
245            && existing
246                .as_deref()
247                .is_some_and(|text| revision_for(text) != expected)
248        {
249            return Err(RuntimeError::ToolFailed(format!(
250                "spec.materialize: revision conflict (expected {expected})"
251            )));
252        }
253        if existing.as_deref() == Some(markdown.as_str()) {
254            return Ok(SpecMaterializeResult {
255                path,
256                revision,
257                changed: false,
258            });
259        }
260        tokio::fs::create_dir_all(self.feature_dir(feature))
261            .await
262            .map_err(|e| {
263                RuntimeError::ToolFailed(format!("spec.materialize: create feature dir: {e}"))
264            })?;
265        let tmp = path.with_extension("md.tmp");
266        tokio::fs::write(&tmp, markdown.as_bytes())
267            .await
268            .map_err(|e| {
269                RuntimeError::ToolFailed(format!("spec.materialize: write temp file: {e}"))
270            })?;
271        tokio::fs::rename(&tmp, &path).await.map_err(|e| {
272            RuntimeError::ToolFailed(format!("spec.materialize: replace file: {e}"))
273        })?;
274        Ok(SpecMaterializeResult {
275            path,
276            revision,
277            changed: true,
278        })
279    }
280}
281
282fn revision_for(text: &str) -> String {
283    use sha2::{Digest, Sha256};
284    let digest = Sha256::digest(text.as_bytes());
285    digest[..8]
286        .iter()
287        .map(|byte| format!("{byte:02x}"))
288        .collect()
289}
290
291fn render_materialized_markdown(
292    feature: &str,
293    entries: &[SpecEntry],
294    deviations: &[SpecDeviation],
295) -> String {
296    let mut out = format!("# Implementation — {feature}\n\n");
297    for entry in entries {
298        out.push_str(&format!(
299            "## {} — {}\n\n{}\n\n",
300            entry.phase, entry.id.0, entry.content
301        ));
302    }
303    if !deviations.is_empty() {
304        out.push_str("## Deviations\n\n");
305        for deviation in deviations {
306            out.push_str(&format!(
307                "- **{}**: {} — {}\n",
308                deviation.section, deviation.delta, deviation.reason
309            ));
310        }
311    }
312    out
313}
314
315fn latest_phase(entries: &[SpecEntry]) -> String {
316    let mut best = 0usize;
317    for e in entries {
318        if let Some(idx) = PHASES.iter().position(|p| *p == e.phase.as_str())
319            && idx + 1 > best
320        {
321            best = idx + 1;
322        }
323    }
324    if best == 0 {
325        "not_started".into()
326    } else {
327        PHASES[best - 1].into()
328    }
329}
330
331fn check_phase_transition(current: &str, next: &str) -> Result<(), String> {
332    let cur_idx = PHASES.iter().position(|p| *p == current).unwrap_or(0);
333    let next_idx = PHASES
334        .iter()
335        .position(|p| *p == next)
336        .ok_or_else(|| format!("unknown phase `{next}`"))?;
337    let is_first = current == "not_started";
338    if is_first && next != PHASES[0] {
339        return Err(format!(
340            "phase gate: must start with `{}`, not `{next}`",
341            PHASES[0]
342        ));
343    }
344    if !is_first && next_idx > cur_idx + 1 {
345        return Err(format!(
346            "phase gate: cannot skip from `{current}` to `{next}` (must go through {})",
347            PHASES[cur_idx + 1]
348        ));
349    }
350    Ok(())
351}
352
353#[cfg(test)]
354mod tests {
355    use super::*;
356
357    async fn store() -> (SpecStore, tempfile::TempDir) {
358        let dir = tempfile::tempdir().unwrap();
359        let store = SpecStore::new(dir.path().to_path_buf());
360        (store, dir)
361    }
362
363    #[tokio::test]
364    async fn new_feature_status_is_not_started() {
365        let (s, _dir) = store().await;
366        let st = s.status("x").await.unwrap();
367        assert_eq!(st.phase, "not_started");
368        assert_eq!(st.entry_count, 0);
369    }
370
371    #[tokio::test]
372    async fn materialize_is_idempotent_and_detects_revision_conflicts() {
373        let (s, dir) = store().await;
374        s.update("x", "research", "notes".into()).await.unwrap();
375        let first = s.materialize("x", None).await.unwrap();
376        assert!(first.changed);
377        assert!(first.path.exists());
378        let second = s.materialize("x", Some(&first.revision)).await.unwrap();
379        assert!(!second.changed);
380        assert_eq!(first.revision, second.revision);
381        tokio::fs::write(&first.path, "user edit\n").await.unwrap();
382        let error = s.materialize("x", Some(&first.revision)).await.unwrap_err();
383        assert!(error.to_string().contains("revision conflict"));
384        assert_eq!(
385            tokio::fs::read_to_string(dir.path().join("x/IMPLEMENTATION.md"))
386                .await
387                .unwrap(),
388            "user edit\n"
389        );
390    }
391
392    #[tokio::test]
393    async fn update_advances_phase() {
394        let (s, _dir) = store().await;
395        s.update("x", "research", "notes".into()).await.unwrap();
396        assert_eq!(s.status("x").await.unwrap().phase, "research");
397        s.update("x", "design", "spec".into()).await.unwrap();
398        assert_eq!(s.status("x").await.unwrap().phase, "design");
399    }
400
401    #[tokio::test]
402    async fn phase_gate_rejects_skip() {
403        let (s, _dir) = store().await;
404        let err = s
405            .update("x", "implementation", "premature".into())
406            .await
407            .unwrap_err();
408        assert!(format!("{err}").contains("must start with `research`"));
409    }
410
411    #[tokio::test]
412    async fn phase_gate_rejects_backwards() {
413        let (s, _dir) = store().await;
414        s.update("x", "research", "r".into()).await.unwrap();
415        s.update("x", "design", "d".into()).await.unwrap();
416        let err = s
417            .update("x", "testing", "premature".into())
418            .await
419            .unwrap_err();
420        assert!(format!("{err}").contains("cannot skip"), "err: {err}");
421    }
422
423    #[tokio::test]
424    async fn deviate_requires_prior_entry() {
425        let (s, _dir) = store().await;
426        let err = s
427            .deviate("x", "sec".into(), "delta".into(), "why".into())
428            .await
429            .unwrap_err();
430        assert!(format!("{err}").contains("no entries"));
431    }
432
433    #[tokio::test]
434    async fn deviate_appends_to_deviations_file() {
435        let (s, _dir) = store().await;
436        s.update("x", "research", "r".into()).await.unwrap();
437        s.update("x", "design", "d".into()).await.unwrap();
438        s.deviate(
439            "x",
440            "data".into(),
441            "added field".into(),
442            "need array".into(),
443        )
444        .await
445        .unwrap();
446        s.deviate("x", "algo".into(), "changed loop".into(), "perf".into())
447            .await
448            .unwrap();
449        let devs = s.deviations("x").await.unwrap();
450        assert_eq!(devs.len(), 2);
451        assert_eq!(s.status("x").await.unwrap().deviation_count, 2);
452    }
453
454    #[tokio::test]
455    async fn update_and_deviate_dual_write_to_index() {
456        let dir = tempfile::tempdir().unwrap();
457        let index = std::sync::Arc::new(AnchorIndex::open_project(dir.path()).unwrap());
458        let s = SpecStore::new(dir.path().to_path_buf()).with_index(index.clone());
459        s.update(
460            "feat_x",
461            "research",
462            "supercalifragilistic research notes".into(),
463        )
464        .await
465        .unwrap();
466        s.update(
467            "feat_x",
468            "design",
469            "midordermetamorphosis design notes".into(),
470        )
471        .await
472        .unwrap();
473        s.deviate(
474            "feat_x",
475            "sec".into(),
476            "hyperloquacious delta text".into(),
477            "quintessentialpolyphony reason text".into(),
478        )
479        .await
480        .unwrap();
481
482        let conn = index.conn();
483        let entry_count: i64 = conn
484            .query_row(
485                "SELECT COUNT(*) FROM spec_entries",
486                rusqlite::params![],
487                |r| r.get(0),
488            )
489            .unwrap();
490        let dev_count: i64 = conn
491            .query_row(
492                "SELECT COUNT(*) FROM spec_deviations",
493                rusqlite::params![],
494                |r| r.get(0),
495            )
496            .unwrap();
497        assert_eq!(entry_count, 2);
498        assert_eq!(dev_count, 1);
499
500        let entry_fts: i64 = conn
501            .query_row(
502                "SELECT COUNT(*) FROM spec_entries_fts WHERE spec_entries_fts MATCH ?",
503                rusqlite::params!["supercalifragilistic"],
504                |r| r.get(0),
505            )
506            .unwrap();
507        assert_eq!(entry_fts, 1);
508
509        let dev_fts: i64 = conn
510            .query_row(
511                "SELECT COUNT(*) FROM spec_deviations_fts WHERE spec_deviations_fts MATCH ?",
512                rusqlite::params!["quintessentialpolyphony"],
513                |r| r.get(0),
514            )
515            .unwrap();
516        assert_eq!(dev_fts, 1);
517    }
518
519    #[tokio::test]
520    async fn unknown_phase_rejected() {
521        let (s, _dir) = store().await;
522        let err = s.update("x", "brainstorm", "n".into()).await.unwrap_err();
523        assert!(format!("{err}").contains("unknown phase"));
524    }
525}