Skip to main content

git_lore/lore/
prism.rs

1use std::fs;
2use std::path::PathBuf;
3use std::time::{SystemTime, UNIX_EPOCH};
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7
8use super::{LoreAtom, Workspace};
9
10pub const PRISM_STALE_TTL_SECONDS: u64 = 24 * 60 * 60;
11
12#[derive(Clone, Debug, Serialize, Deserialize)]
13#[serde(rename_all = "snake_case")]
14pub struct PrismSignal {
15    pub session_id: String,
16    pub agent: Option<String>,
17    pub scope: Option<String>,
18    pub paths: Vec<String>,
19    pub assumptions: Vec<String>,
20    pub decision: Option<String>,
21    pub created_unix_seconds: u64,
22}
23
24#[derive(Clone, Debug, PartialEq, Eq)]
25pub struct PrismConflict {
26    pub session_id: String,
27    pub agent: Option<String>,
28    pub scope: Option<String>,
29    pub decision: Option<String>,
30    pub overlapping_paths: Vec<String>,
31}
32
33#[derive(Clone, Debug, PartialEq, Eq)]
34pub struct HardLockViolation {
35    pub session_id: String,
36    pub message: String,
37    pub atom_ids: Vec<String>,
38}
39
40impl PrismSignal {
41    pub fn new(
42        session_id: String,
43        agent: Option<String>,
44        scope: Option<String>,
45        mut paths: Vec<String>,
46        assumptions: Vec<String>,
47        decision: Option<String>,
48    ) -> Self {
49        paths.sort();
50        paths.dedup();
51
52        Self {
53            session_id,
54            agent,
55            scope,
56            paths,
57            assumptions,
58            decision,
59            created_unix_seconds: now_unix_seconds(),
60        }
61    }
62}
63
64impl Workspace {
65    pub fn write_prism_signal(&self, signal: &PrismSignal) -> Result<()> {
66        self.ensure_layout()?;
67        let signal_path = self.prism_signal_path(&signal.session_id);
68        self.write_json(&signal_path, signal)
69    }
70
71    pub fn load_prism_signals(&self) -> Result<Vec<PrismSignal>> {
72        self.ensure_layout()?;
73
74        let mut signals = Vec::new();
75        for entry in fs::read_dir(self.prism_dir())? {
76            let entry = entry?;
77            let path = entry.path();
78            if path.extension().and_then(|value| value.to_str()) != Some("signal") {
79                continue;
80            }
81
82            let signal: PrismSignal = self.read_json(&path)?;
83            signals.push(signal);
84        }
85
86        signals.sort_by(|left, right| left.session_id.cmp(&right.session_id));
87        Ok(signals)
88    }
89
90    pub fn scan_prism_conflicts(&self, current_signal: &PrismSignal) -> Result<Vec<PrismConflict>> {
91        let mut conflicts = Vec::new();
92        let now = now_unix_seconds();
93
94        for signal in self.load_prism_signals()? {
95            if signal.session_id == current_signal.session_id {
96                continue;
97            }
98
99            if is_stale_signal(&signal, now, PRISM_STALE_TTL_SECONDS) {
100                continue;
101            }
102
103            let overlapping_paths = current_signal
104                .paths
105                .iter()
106                .flat_map(|current_path| {
107                    signal
108                        .paths
109                        .iter()
110                        .filter(move |other_path| patterns_may_overlap(current_path, other_path))
111                        .cloned()
112                })
113                .collect::<Vec<_>>();
114
115            if overlapping_paths.is_empty() {
116                continue;
117            }
118
119            conflicts.push(PrismConflict {
120                session_id: signal.session_id,
121                agent: signal.agent,
122                scope: signal.scope,
123                decision: signal.decision,
124                overlapping_paths,
125            });
126        }
127
128        Ok(conflicts)
129    }
130
131    pub fn scan_prism_hard_locks(&self, active_atoms: &[LoreAtom]) -> Result<Vec<HardLockViolation>> {
132        let mut violations = Vec::new();
133        let now = now_unix_seconds();
134
135        for signal in self.load_prism_signals()? {
136            if is_stale_signal(&signal, now, PRISM_STALE_TTL_SECONDS) {
137                continue;
138            }
139
140            let Some(decision) = signal.decision.as_deref() else {
141                continue;
142            };
143
144            let overlapping_atoms = active_atoms
145                .iter()
146                .filter(|atom| atom_overlaps_signal(atom, &signal))
147                .filter(|atom| atom.title != decision && atom.body.as_deref() != Some(decision))
148                .collect::<Vec<_>>();
149
150            if overlapping_atoms.is_empty() {
151                continue;
152            }
153
154            violations.push(HardLockViolation {
155                session_id: signal.session_id.clone(),
156                message: format!(
157                    "hard-lock from session {} blocks overlapping active lore",
158                    signal.session_id
159                ),
160                atom_ids: overlapping_atoms
161                    .into_iter()
162                    .map(|atom| atom.id.clone())
163                    .collect(),
164            });
165        }
166
167        Ok(violations)
168    }
169
170    fn prism_signal_path(&self, session_id: &str) -> PathBuf {
171        self.prism_dir().join(format!("{session_id}.signal"))
172    }
173
174    pub fn count_stale_prism_signals(&self, stale_ttl_seconds: u64) -> Result<usize> {
175        let now = now_unix_seconds();
176        Ok(self
177            .load_prism_signals()?
178            .into_iter()
179            .filter(|signal| is_stale_signal(signal, now, stale_ttl_seconds))
180            .count())
181    }
182
183    pub fn prune_stale_prism_signals(&self, stale_ttl_seconds: u64) -> Result<usize> {
184        self.ensure_layout()?;
185        let now = now_unix_seconds();
186        let mut pruned = 0usize;
187
188        for signal in self.load_prism_signals()? {
189            if !is_stale_signal(&signal, now, stale_ttl_seconds) {
190                continue;
191            }
192
193            let path = self.prism_signal_path(&signal.session_id);
194            if path.exists() {
195                fs::remove_file(&path)?;
196                pruned += 1;
197            }
198        }
199
200        Ok(pruned)
201    }
202}
203
204fn patterns_may_overlap(left: &str, right: &str) -> bool {
205    let left = normalize_pattern(left);
206    let right = normalize_pattern(right);
207
208    if left == right {
209        return true;
210    }
211
212    let left_has_glob = left.chars().any(is_glob_char);
213    let right_has_glob = right.chars().any(is_glob_char);
214
215    if !left_has_glob && !right_has_glob {
216        return path_prefix_overlap(&left, &right);
217    }
218
219    let left_prefix = literal_prefix(&left);
220    let right_prefix = literal_prefix(&right);
221
222    if left_prefix.is_empty() || right_prefix.is_empty() {
223        return true;
224    }
225
226    left_prefix == right_prefix || path_prefix_overlap(&left_prefix, &right_prefix)
227}
228
229fn literal_prefix(pattern: &str) -> String {
230    let mut segments = Vec::new();
231    for segment in pattern.split('/') {
232        if segment.chars().any(is_glob_char) {
233            break;
234        }
235        if segment.is_empty() {
236            continue;
237        }
238        segments.push(segment);
239    }
240
241    segments.join("/")
242}
243
244fn path_prefix_overlap(left: &str, right: &str) -> bool {
245    if left == right {
246        return true;
247    }
248
249    let left = left.trim_end_matches('/');
250    let right = right.trim_end_matches('/');
251
252    left.starts_with(&(right.to_string() + "/")) || right.starts_with(&(left.to_string() + "/"))
253}
254
255fn normalize_pattern(pattern: &str) -> String {
256    pattern.replace('\\', "/")
257}
258
259fn is_glob_char(character: char) -> bool {
260    matches!(character, '*' | '?' | '[' | ']')
261}
262
263fn atom_overlaps_signal(atom: &LoreAtom, signal: &PrismSignal) -> bool {
264    let atom_path = atom.path.as_ref().map(|path| path.to_string_lossy().replace('\\', "/"));
265    let atom_scope = atom.scope.as_deref();
266
267    signal.paths.iter().any(|signal_path| {
268        atom_path
269            .as_deref()
270            .map(|path| patterns_may_overlap(path, signal_path))
271            .unwrap_or(false)
272            || atom_scope
273                .zip(signal.scope.as_deref())
274                .map(|(left, right)| left == right)
275                .unwrap_or(false)
276    })
277}
278
279fn is_stale_signal(signal: &PrismSignal, now_unix_seconds: u64, stale_ttl_seconds: u64) -> bool {
280    now_unix_seconds.saturating_sub(signal.created_unix_seconds) > stale_ttl_seconds
281}
282
283fn now_unix_seconds() -> u64 {
284    SystemTime::now()
285        .duration_since(UNIX_EPOCH)
286        .map(|duration| duration.as_secs())
287        .unwrap_or(0)
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use uuid::Uuid;
294    use std::path::PathBuf;
295
296    #[test]
297    fn overlapping_signals_are_reported() {
298        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-test-{}", Uuid::new_v4()));
299        fs::create_dir_all(&temp_root).unwrap();
300        let workspace = Workspace::init(&temp_root).unwrap();
301
302        let existing = PrismSignal::new(
303            "session-b".to_string(),
304            Some("agent-b".to_string()),
305            Some("db".to_string()),
306            vec!["src/db/**".to_string()],
307            vec!["Database layer is stable".to_string()],
308            Some("refactor db".to_string()),
309        );
310        workspace.write_prism_signal(&existing).unwrap();
311
312        let current = PrismSignal::new(
313            "session-a".to_string(),
314            Some("agent-a".to_string()),
315            Some("api".to_string()),
316            vec!["src/db/models.rs".to_string()],
317            vec!["Assuming schema compatibility".to_string()],
318            None,
319        );
320
321        let conflicts = workspace.scan_prism_conflicts(&current).unwrap();
322        assert_eq!(conflicts.len(), 1);
323        assert_eq!(conflicts[0].session_id, "session-b");
324        assert_eq!(conflicts[0].overlapping_paths, vec!["src/db/**".to_string()]);
325    }
326
327    #[test]
328    fn non_overlapping_signals_are_ignored() {
329        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-test-{}", Uuid::new_v4()));
330        fs::create_dir_all(&temp_root).unwrap();
331        let workspace = Workspace::init(&temp_root).unwrap();
332
333        let existing = PrismSignal::new(
334            "session-b".to_string(),
335            Some("agent-b".to_string()),
336            Some("docs".to_string()),
337            vec!["docs/**/*.md".to_string()],
338            vec![],
339            None,
340        );
341        workspace.write_prism_signal(&existing).unwrap();
342
343        let current = PrismSignal::new(
344            "session-a".to_string(),
345            Some("agent-a".to_string()),
346            Some("src".to_string()),
347            vec!["src/**/*.rs".to_string()],
348            vec![],
349            None,
350        );
351
352        let conflicts = workspace.scan_prism_conflicts(&current).unwrap();
353        assert!(conflicts.is_empty());
354    }
355
356    #[test]
357    fn conflicting_decisions_trigger_a_hard_lock() {
358        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-hardlock-{}", Uuid::new_v4()));
359        fs::create_dir_all(&temp_root).unwrap();
360        let workspace = Workspace::init(&temp_root).unwrap();
361
362        let existing = PrismSignal::new(
363            "session-b".to_string(),
364            Some("agent-b".to_string()),
365            Some("db".to_string()),
366            vec!["src/db/**".to_string()],
367            vec![],
368            Some("Use SQLite".to_string()),
369        );
370        workspace.write_prism_signal(&existing).unwrap();
371
372        let atom = LoreAtom {
373            id: Uuid::new_v4().to_string(),
374            kind: super::super::LoreKind::Decision,
375            state: super::super::AtomState::Proposed,
376            title: "Use Postgres".to_string(),
377            body: None,
378            scope: Some("db".to_string()),
379            path: Some(PathBuf::from("src/db/models.rs")),
380            validation_script: None,
381            created_unix_seconds: 0,
382        };
383
384        let violations = workspace.scan_prism_hard_locks(&[atom]).unwrap();
385        assert_eq!(violations.len(), 1);
386        assert_eq!(violations[0].session_id, "session-b");
387    }
388
389    #[test]
390    fn stale_signals_are_ignored_for_conflicts_and_hard_locks() {
391        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-stale-{}", Uuid::new_v4()));
392        fs::create_dir_all(&temp_root).unwrap();
393        let workspace = Workspace::init(&temp_root).unwrap();
394
395        let mut stale = PrismSignal::new(
396            "stale-session".to_string(),
397            Some("agent-stale".to_string()),
398            Some("db".to_string()),
399            vec!["src/db/**".to_string()],
400            vec![],
401            Some("Use SQLite".to_string()),
402        );
403        stale.created_unix_seconds = now_unix_seconds().saturating_sub(PRISM_STALE_TTL_SECONDS + 10);
404        workspace.write_prism_signal(&stale).unwrap();
405
406        let current = PrismSignal::new(
407            "current-session".to_string(),
408            Some("agent-current".to_string()),
409            Some("db".to_string()),
410            vec!["src/db/models.rs".to_string()],
411            vec![],
412            None,
413        );
414
415        let conflicts = workspace.scan_prism_conflicts(&current).unwrap();
416        assert!(conflicts.is_empty());
417
418        let atom = LoreAtom {
419            id: Uuid::new_v4().to_string(),
420            kind: super::super::LoreKind::Decision,
421            state: super::super::AtomState::Proposed,
422            title: "Use Postgres".to_string(),
423            body: None,
424            scope: Some("db".to_string()),
425            path: Some(PathBuf::from("src/db/models.rs")),
426            validation_script: None,
427            created_unix_seconds: 0,
428        };
429        let hard_locks = workspace.scan_prism_hard_locks(&[atom]).unwrap();
430        assert!(hard_locks.is_empty());
431    }
432
433    #[test]
434    fn stale_signal_pruning_removes_expired_entries() {
435        let temp_root = std::env::temp_dir().join(format!("git-lore-prism-prune-{}", Uuid::new_v4()));
436        fs::create_dir_all(&temp_root).unwrap();
437        let workspace = Workspace::init(&temp_root).unwrap();
438
439        let mut stale = PrismSignal::new(
440            "stale-session".to_string(),
441            Some("agent-stale".to_string()),
442            None,
443            vec!["src/**".to_string()],
444            vec![],
445            None,
446        );
447        stale.created_unix_seconds = now_unix_seconds().saturating_sub(PRISM_STALE_TTL_SECONDS + 10);
448        workspace.write_prism_signal(&stale).unwrap();
449
450        let fresh = PrismSignal::new(
451            "fresh-session".to_string(),
452            Some("agent-fresh".to_string()),
453            None,
454            vec!["src/**".to_string()],
455            vec![],
456            None,
457        );
458        workspace.write_prism_signal(&fresh).unwrap();
459
460        let stale_before = workspace.count_stale_prism_signals(PRISM_STALE_TTL_SECONDS).unwrap();
461        assert_eq!(stale_before, 1);
462
463        let removed = workspace.prune_stale_prism_signals(PRISM_STALE_TTL_SECONDS).unwrap();
464        assert_eq!(removed, 1);
465
466        let remaining = workspace.load_prism_signals().unwrap();
467        assert_eq!(remaining.len(), 1);
468        assert_eq!(remaining[0].session_id, "fresh-session");
469    }
470}