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