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(¤t).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(¤t).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(¤t).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}