1use std::collections::BTreeMap;
11use std::path::Path;
12use std::time::{SystemTime, UNIX_EPOCH};
13
14use anyhow::{Context, Result};
15use sha2::{Digest, Sha256};
16
17use super::{
18 Category, ConfidenceScore, FileRecord, GotchaRecord, Priority, QualityScore, ReceiptSource,
19 Record, RecordLifecycle, RecordSource, RecordVersion, RepoIdent, StaleReviewEntry,
20 StaleReviewPayload, StalenessScore, StalenessTier, Store,
21};
22use crate::health::staleness::StalenessAnalyzer;
23
24pub fn now_secs() -> u64 {
27 SystemTime::now()
28 .duration_since(UNIX_EPOCH)
29 .unwrap_or_default()
30 .as_secs()
31}
32
33pub fn today_key(prefix: &str) -> String {
34 let now = chrono::Utc::now().format("%Y-%m-%d");
35 format!("{prefix}{now}")
36}
37
38pub fn session_record(key: &str, value: String) -> Record {
39 let now = now_secs();
40 Record {
41 key: key.to_string(),
42 value,
43 category: Category::Session,
44 priority: Priority::Normal,
45 tags: vec![],
46 created_at: now,
47 updated_at: now,
48 ref_url: None,
49 staleness: StalenessScore::fresh(),
50 lifecycle: RecordLifecycle::Active,
51 version: RecordVersion {
52 device_id: crate::store::stable_device_id(),
53 logical_clock: 1,
54 wall_clock: now,
55 },
56 quality: QualityScore::layer0_default(),
57 access_count: 0,
58 last_accessed: 0,
59 source: RecordSource::SessionHook,
60 confidence: ConfidenceScore::for_new_record(&RecordSource::SessionHook),
61 gap_analysis_score: 0.0,
62 payload: None,
63 }
64}
65
66pub fn analytics_record(key: &str, value: String) -> Record {
67 let mut r = session_record(key, value);
68 r.category = Category::Analytics;
69 r
70}
71
72pub const SUBAGENT_SUMMARY_KEY: &str = "session:summary:latest";
76
77const SUBAGENT_SUMMARY_MAX: usize = 800;
80
81fn truncate_summary(s: &str) -> String {
82 if s.chars().count() <= SUBAGENT_SUMMARY_MAX {
83 return s.to_string();
84 }
85 let mut out: String = s.chars().take(SUBAGENT_SUMMARY_MAX).collect();
86 out.push('…');
87 out
88}
89
90pub async fn write_subagent_summary(
93 store: &Store,
94 summary: &str,
95 agent_id: Option<&str>,
96 agent_type: Option<&str>,
97 session_id: Option<&str>,
98 transcript_path: Option<&str>,
99) -> Result<()> {
100 let trimmed = summary.trim();
101 if trimmed.is_empty() {
102 return Ok(());
103 }
104 let mut record = session_record(SUBAGENT_SUMMARY_KEY, truncate_summary(trimmed));
105 record.payload = Some(serde_json::json!({
106 "agent_id": agent_id,
107 "agent_type": agent_type,
108 "session_id": session_id,
109 "transcript_path": transcript_path,
110 }));
111 store.put(SUBAGENT_SUMMARY_KEY, &record).await
112}
113
114pub fn instructions_loaded_record(
116 key: &str,
117 payload: &crate::hooks::decide::InstructionsLoadedPayload,
118) -> Result<Record> {
119 let mut record = session_record(key, payload.file_path.clone());
120 record.payload = Some(serde_json::to_value(payload)?);
121 Ok(record)
122}
123
124pub async fn record_instructions_loaded(
129 store: &Store,
130 payload: &crate::hooks::decide::InstructionsLoadedPayload,
131) -> Result<String> {
132 let key = format!("hook_event:instructions_loaded:{}", uuid::Uuid::now_v7());
133 let record = instructions_loaded_record(&key, payload)?;
134 store.put(&key, &record).await?;
135 Ok(key)
136}
137
138#[derive(serde::Serialize, serde::Deserialize, Debug)]
140pub struct DailyAgg {
141 pub count: u64,
142 pub keys: Vec<String>,
143 #[serde(default)]
146 pub key_counts: BTreeMap<String, u64>,
147}
148
149pub const MAX_AGG_KEYS: usize = 100;
150pub const MAX_SHADOW_OBSERVATIONS: usize = 100;
151
152#[derive(serde::Serialize, serde::Deserialize, Debug, Clone)]
153pub struct ShadowObservation {
154 pub policy_key: String,
155 pub action: crate::hooks::decide::Action,
156 pub timestamp: u64,
157 pub would: crate::hooks::decide::ShadowOutcome,
158}
159
160#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
161pub struct ShadowObservationAgg {
162 pub policies: BTreeMap<String, PolicyShadowAgg>,
163}
164
165#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
166pub struct PolicyShadowAgg {
167 pub count: u64,
168 pub observations: Vec<ShadowObservation>,
169}
170
171pub fn shadow_observation_key() -> String {
172 today_key("analytics:policy_shadow_")
173}
174
175pub async fn record_shadow_observation(
179 store: &Store,
180 policy_key: &str,
181 action: &crate::hooks::decide::Action,
182 would: crate::hooks::decide::ShadowOutcome,
183) -> Result<()> {
184 let key = shadow_observation_key();
185 let now = now_secs();
186 let mut record = store
187 .get(&key)
188 .await?
189 .unwrap_or_else(|| analytics_record(&key, String::new()));
190 let mut agg: ShadowObservationAgg = record.payload_as().unwrap_or_default();
191 let policy = agg.policies.entry(policy_key.to_string()).or_default();
192 policy.count += 1;
193 policy.observations.push(ShadowObservation {
194 policy_key: policy_key.to_string(),
195 action: action.clone(),
196 timestamp: now,
197 would,
198 });
199 if policy.observations.len() > MAX_SHADOW_OBSERVATIONS {
200 policy.observations.remove(0);
201 }
202 record.payload = Some(serde_json::to_value(&agg)?);
203 record.updated_at = now;
204 record.version.logical_clock += 1;
205 record.version.wall_clock = now;
206 store.put(&key, &record).await
207}
208
209const STALE_REVIEW_MIN: f32 = 0.4;
211const STALE_REVIEW_MAX: f32 = 0.7;
213pub const CONSULTED_RECENT_TTL_SECS: u64 = 900;
215pub const MAX_STALE_REVIEW_ENTRIES: usize = 20;
217pub const GOTCHA_PROMOTION_ACCESS_THRESHOLD: u32 = 3;
219
220#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, Default)]
221pub struct ConsultationReceipt {
222 #[serde(default)]
223 pub fingerprint: Option<String>,
224 #[serde(default)]
228 pub id: Option<String>,
229 #[serde(default)]
237 pub source: Option<ReceiptSource>,
238}
239
240pub struct StagedReceipt {
243 pub key: String,
244 pub bytes: Vec<u8>,
245 pub id: String,
246}
247
248pub fn record_content_fingerprint(record: &Record) -> Option<String> {
252 let content = (
253 &record.key,
254 &record.value,
255 &record.category,
256 &record.priority,
257 &record.tags,
258 &record.ref_url,
259 &record.staleness,
260 &record.lifecycle,
261 &record.quality,
262 &record.source,
263 &record.confidence,
264 &record.gap_analysis_score,
265 &record.payload,
266 );
267 let bytes = rmp_serde::to_vec_named(&content).ok()?;
268 let mut hasher = Sha256::new();
269 hasher.update(bytes);
270 Some(format!("{:x}", hasher.finalize()))
271}
272
273pub fn worktree_scope_tag(cwd: &Path) -> Option<String> {
288 worktree_scope_tag_for(&RepoIdent::discover(cwd))
289}
290
291pub fn worktree_scope_tag_for(ident: &RepoIdent) -> Option<String> {
295 let workdir = ident.workdir.as_ref()?;
296 let canon = std::fs::canonicalize(workdir).unwrap_or_else(|_| workdir.clone());
297 let digest = Sha256::digest(canon.to_string_lossy().as_bytes());
298 Some(hex::encode(&digest[..4]))
299}
300
301pub fn combined_actor_scope(worktree: Option<&str>, agent_id: Option<&str>) -> Option<String> {
305 match (worktree, agent_id) {
306 (Some(w), Some(a)) => Some(format!("{w}:{a}")),
307 (Some(w), None) => Some(w.to_string()),
308 (None, Some(a)) => Some(a.to_string()),
309 (None, None) => None,
310 }
311}
312
313pub async fn upsert_daily_agg(store: &Store, agg_key: &str, target_key: &str) -> Result<()> {
314 let now = now_secs();
315
316 match store.get(agg_key).await? {
317 Some(mut record) => {
318 let mut agg: DailyAgg = record.payload_as::<DailyAgg>().unwrap_or(DailyAgg {
319 count: 0,
320 keys: vec![],
321 key_counts: BTreeMap::new(),
322 });
323 agg.count += 1;
324 *agg.key_counts.entry(target_key.to_string()).or_default() += 1;
325 if agg.keys.len() < MAX_AGG_KEYS && !agg.keys.iter().any(|k| k == target_key) {
326 agg.keys.push(target_key.to_string());
327 }
328 record.payload = serde_json::to_value(&agg).ok();
329 record.updated_at = now;
330 record.version.logical_clock += 1;
331 record.version.wall_clock = now;
332 store.put(agg_key, &record).await?;
333 }
334 None => {
335 let agg = DailyAgg {
336 count: 1,
337 keys: vec![target_key.to_string()],
338 key_counts: BTreeMap::from([(target_key.to_string(), 1)]),
339 };
340 let mut record = analytics_record(agg_key, String::new());
341 record.payload = serde_json::to_value(&agg).ok();
342 store.put(agg_key, &record).await?;
343 }
344 }
345
346 Ok(())
347}
348
349pub async fn upsert_daily_agg_staged(
355 store: &Store,
356 agg_key: &str,
357 target_key: &str,
358) -> Result<(String, Vec<u8>)> {
359 let now = now_secs();
360
361 let record = match store.get(agg_key).await? {
362 Some(mut record) => {
363 let mut agg: DailyAgg = record.payload_as::<DailyAgg>().unwrap_or(DailyAgg {
364 count: 0,
365 keys: vec![],
366 key_counts: BTreeMap::new(),
367 });
368 agg.count += 1;
369 *agg.key_counts.entry(target_key.to_string()).or_default() += 1;
370 if agg.keys.len() < MAX_AGG_KEYS && !agg.keys.iter().any(|k| k == target_key) {
371 agg.keys.push(target_key.to_string());
372 }
373 record.payload = serde_json::to_value(&agg).ok();
374 record.updated_at = now;
375 record.version.logical_clock += 1;
376 record.version.wall_clock = now;
377 record
378 }
379 None => {
380 let agg = DailyAgg {
381 count: 1,
382 keys: vec![target_key.to_string()],
383 key_counts: BTreeMap::from([(target_key.to_string(), 1)]),
384 };
385 let mut record = analytics_record(agg_key, String::new());
386 record.payload = serde_json::to_value(&agg).ok();
387 record
388 }
389 };
390
391 let bytes = rmp_serde::to_vec_named(&record)
392 .with_context(|| format!("failed to serialize agg record for {agg_key}"))?;
393 Ok((agg_key.to_string(), bytes))
394}
395
396fn receipt_key(key: &str, actor: Option<&str>) -> String {
397 match actor {
398 Some(a) => format!("session:consulted:{a}:{key}"),
399 None => format!("session:consulted:{key}"),
400 }
401}
402
403pub fn consultation_receipt_staged(key: &str, actor: Option<&str>) -> Result<StagedReceipt> {
408 consultation_receipt_staged_with_fingerprint(key, actor, None, None)
409}
410
411pub fn consultation_receipt_staged_with_fingerprint(
412 key: &str,
413 actor: Option<&str>,
414 fingerprint: Option<String>,
415 source: Option<ReceiptSource>,
416) -> Result<StagedReceipt> {
417 let consulted_key = receipt_key(key, actor);
418 let id = uuid::Uuid::now_v7().to_string();
419 let mut record = session_record(&consulted_key, String::new());
420 record.payload = Some(serde_json::to_value(ConsultationReceipt {
421 fingerprint,
422 id: Some(id.clone()),
423 source,
424 })?);
425 let bytes = rmp_serde::to_vec_named(&record)
426 .with_context(|| format!("failed to serialize consulted receipt for {consulted_key}"))?;
427 Ok(StagedReceipt {
428 key: consulted_key,
429 bytes,
430 id,
431 })
432}
433
434pub async fn receipt_id_in_force(store: &Store, key: &str, actor: Option<&str>) -> Option<String> {
440 let record = store.get(&receipt_key(key, actor)).await.ok().flatten()?;
441 record.payload_as::<ConsultationReceipt>()?.id
442}
443
444pub async fn consultation_receipt_staged_for_store(
445 store: &Store,
446 key: &str,
447 actor: Option<&str>,
448 capture_fingerprint: bool,
449 source: Option<ReceiptSource>,
450) -> Result<StagedReceipt> {
451 let fingerprint = if capture_fingerprint {
452 store
453 .get(key)
454 .await
455 .ok()
456 .flatten()
457 .and_then(|record| record_content_fingerprint(&record))
458 } else {
459 None
460 };
461 consultation_receipt_staged_with_fingerprint(key, actor, fingerprint, source)
462}
463
464pub async fn session_flush_staged(store: &Store) -> Result<Option<(String, Vec<u8>)>> {
468 let now = now_secs();
469 let consulted_keys = store.scan_keys("session:consulted:").await?;
470 let stripped: Vec<String> = consulted_keys
471 .iter()
472 .map(|k| {
473 k.strip_prefix("session:consulted:")
474 .unwrap_or(k)
475 .to_string()
476 })
477 .collect();
478
479 let session_data = serde_json::json!({
480 "consulted_keys": stripped,
481 "flushed_at": now,
482 });
483 let mut rec = session_record("session:current", String::new());
484 rec.payload = Some(session_data);
485 let bytes = rmp_serde::to_vec_named(&rec)?;
486 Ok(Some(("session:current".to_string(), bytes)))
487}
488
489pub async fn log_hit(store: &Store, key: &str) -> Result<()> {
493 let now = now_secs();
494
495 let agg_key = today_key("analytics:hit_");
497 upsert_daily_agg(store, &agg_key, key).await?;
498
499 let staged = consultation_receipt_staged_for_store(store, key, None, true, None).await?;
503 let receipt: Record = rmp_serde::from_slice(&staged.bytes)
504 .context("failed to deserialize staged consultation receipt")?;
505 store.put(&staged.key, &receipt).await?;
506
507 if let Some(mut record) = store.get(key).await? {
509 record.access_count += 1;
510 record.last_accessed = now;
511 store.put(key, &record).await?;
512 }
513
514 let _ = crate::store::enforcement::record_event(
523 store,
524 crate::store::enforcement::EnforcementEventType::ReceiptMinted,
525 crate::store::enforcement::SubjectKind::File,
526 key.to_string(),
527 "claude".to_string(),
528 Some(staged.id),
529 "consultation_requested".to_string(),
530 None,
531 )
532 .await;
533
534 Ok(())
535}
536
537pub async fn log_miss(store: &Store, key: &str) -> Result<()> {
541 let agg_key = today_key("analytics:miss_");
542 upsert_daily_agg(store, &agg_key, key).await
543}
544
545pub async fn log_compliance_miss(store: &Store, key: &str) -> Result<()> {
549 let agg_key = today_key("compliance:miss_");
550 upsert_daily_agg(store, &agg_key, key).await
551}
552
553pub async fn log_compliance_hit(store: &Store, key: &str) -> Result<()> {
557 let agg_key = today_key("compliance:allow_after_receipt_");
558 upsert_daily_agg(store, &agg_key, key).await
559}
560
561pub async fn log_codex_shell_miss(store: &Store, key: &str) -> Result<()> {
563 let agg_key = today_key("compliance:codex_shell_miss_");
564 upsert_daily_agg(store, &agg_key, key).await
565}
566
567pub async fn log_prompt_nudge(store: &Store, key: &str) -> Result<()> {
569 let agg_key = today_key("analytics:codex_prompt_nudge_");
570 upsert_daily_agg(store, &agg_key, key).await
571}
572
573pub async fn log_bootstrap(store: &Store, key: &str) -> Result<()> {
575 let agg_key = today_key("analytics:bootstrap_");
576 upsert_daily_agg(store, &agg_key, key).await
577}
578
579pub async fn check_consulted(store: &Store, key: &str, actor: Option<&str>) -> Result<bool> {
587 let consulted_key = receipt_key(key, actor);
588 Ok(store.get(&consulted_key).await?.is_some())
589}
590
591pub async fn check_consulted_recent(
598 store: &Store,
599 key: &str,
600 ttl_secs: u64,
601 actor: Option<&str>,
602) -> Result<bool> {
603 let consulted_key = receipt_key(key, actor);
604 let Some(record) = store.get(&consulted_key).await? else {
605 return Ok(false);
606 };
607 let age = now_secs().saturating_sub(record.updated_at);
608 Ok(age <= ttl_secs)
609}
610
611pub async fn check_consulted_recent_with_sources(
615 store: &Store,
616 key: &str,
617 ttl_secs: u64,
618 actor: Option<&str>,
619 accepted_sources: &[ReceiptSource],
620) -> Result<bool> {
621 let consulted_key = receipt_key(key, actor);
622 let Some(record) = store.get(&consulted_key).await? else {
623 return Ok(false);
624 };
625 if now_secs().saturating_sub(record.updated_at) > ttl_secs {
626 return Ok(false);
627 }
628 let payload = record
629 .payload
630 .ok_or_else(|| anyhow::anyhow!("consultation receipt {consulted_key} has no payload"))?;
631 let receipt: ConsultationReceipt = serde_json::from_value(payload)
632 .with_context(|| format!("invalid consultation receipt payload at {consulted_key}"))?;
633 Ok(receipt
634 .source
635 .is_some_and(|source| accepted_sources.contains(&source)))
636}
637
638pub async fn check_consulted_recent_fingerprinted(
642 store: &Store,
643 key: &str,
644 ttl_secs: u64,
645 actor: Option<&str>,
646) -> Result<bool> {
647 let consulted_key = receipt_key(key, actor);
648 let Some(receipt) = store.get(&consulted_key).await? else {
649 return Ok(false);
650 };
651 if now_secs().saturating_sub(receipt.updated_at) > ttl_secs {
652 return Ok(false);
653 }
654 let Some(stored) = receipt
655 .payload_as::<ConsultationReceipt>()
656 .and_then(|payload| payload.fingerprint)
657 else {
658 return Ok(false);
659 };
660 let current = store
662 .get(key)
663 .await?
664 .and_then(|record| record_content_fingerprint(&record));
665 Ok(current.is_some_and(|current| current == stored))
666}
667
668pub async fn check_consulted_recent_fingerprinted_with_sources(
671 store: &Store,
672 key: &str,
673 ttl_secs: u64,
674 actor: Option<&str>,
675 accepted_sources: &[ReceiptSource],
676) -> Result<bool> {
677 let consulted_key = receipt_key(key, actor);
678 let Some(receipt_record) = store.get(&consulted_key).await? else {
679 return Ok(false);
680 };
681 if now_secs().saturating_sub(receipt_record.updated_at) > ttl_secs {
682 return Ok(false);
683 }
684 let payload = receipt_record
685 .payload
686 .ok_or_else(|| anyhow::anyhow!("consultation receipt {consulted_key} has no payload"))?;
687 let receipt: ConsultationReceipt = serde_json::from_value(payload)
688 .with_context(|| format!("invalid consultation receipt payload at {consulted_key}"))?;
689 if !receipt
690 .source
691 .is_some_and(|source| accepted_sources.contains(&source))
692 {
693 return Ok(false);
694 }
695 let Some(stored) = receipt.fingerprint else {
696 return Ok(false);
697 };
698 let current = store
701 .get(key)
702 .await?
703 .and_then(|record| record_content_fingerprint(&record));
704 Ok(current.is_some_and(|current| current == stored))
705}
706
707pub async fn session_flush(store: &Store) -> Result<()> {
711 let now = now_secs();
712
713 let consulted_keys = store.scan_keys("session:consulted:").await?;
714 let stripped: Vec<String> = consulted_keys
715 .iter()
716 .map(|k| {
717 k.strip_prefix("session:consulted:")
718 .unwrap_or(k)
719 .to_string()
720 })
721 .collect();
722
723 let session_data = serde_json::json!({
724 "consulted_keys": stripped,
725 "flushed_at": now,
726 });
727 let mut rec = session_record("session:current", String::new());
728 rec.payload = Some(session_data);
729 store.put("session:current", &rec).await?;
730 Ok(())
731}
732
733async fn delete_all_receipts(store: &Store) -> Result<()> {
739 let consulted_keys = store.scan_keys("session:consulted:").await?;
740 for k in &consulted_keys {
741 store.delete(k).await?;
742 }
743 Ok(())
744}
745
746pub async fn session_clear_consults(store: &Store) -> Result<()> {
752 delete_all_receipts(store).await
753}
754
755pub async fn session_harvest(store: &Store, repo_root: &Path) -> Result<()> {
767 let now = now_secs();
768
769 match promote_gotcha_candidates(store).await {
771 Ok(n) if n > 0 => tracing::info!(promoted = n, "gotcha candidates auto-promoted"),
772 Ok(_) => {}
773 Err(e) => tracing::warn!(error = %e, "gotcha promotion failed"),
774 }
775
776 match StalenessAnalyzer::new(repo_root).analyze_all(store).await {
778 Ok(report) if report.updated > 0 => {
779 tracing::info!(
780 scanned = report.scanned,
781 updated = report.updated,
782 tombstoned = report.tombstoned,
783 liability = report.liability,
784 "staleness analysis complete"
785 );
786 }
787 Ok(_) => {}
788 Err(e) => tracing::warn!(error = %e, "staleness analysis failed"),
789 }
790
791 let _ = store.delete(SUBAGENT_SUMMARY_KEY).await;
795
796 let session_rec = match store.get("session:current").await? {
798 Some(r) => r,
799 None => return Ok(()),
800 };
801
802 let session_value = match session_rec.payload.as_ref() {
803 Some(p) => serde_json::to_string(p).unwrap_or_default(),
804 None => session_rec.value.clone(),
805 };
806
807 match collect_and_store_stale_reviews(store, &session_value, now).await {
809 Ok(n) if n > 0 => tracing::info!(entries = n, "stale review entries collected"),
810 Ok(_) => {}
811 Err(e) => tracing::warn!(error = %e, "stale review collection failed"),
812 }
813
814 let session_key = format!("session:{now}");
816 let mut perm = session_record(&session_key, session_value);
817 perm.payload = session_rec.payload;
818 store.put(&session_key, &perm).await?;
819
820 delete_all_receipts(store).await?;
822
823 if let Some(mut stage) = store.get("stage:current").await? {
825 stage.updated_at = now;
826 stage.version.logical_clock += 1;
827 stage.version.wall_clock = now;
828 let base = stage
829 .value
830 .lines()
831 .filter(|l| !l.starts_with("last_session:"))
832 .collect::<Vec<_>>()
833 .join("\n");
834 stage.value = if base.is_empty() {
835 format!("last_session: {session_key}")
836 } else {
837 format!("{base}\nlast_session: {session_key}")
838 };
839 store.put("stage:current", &stage).await?;
840 }
841
842 Ok(())
843}
844
845pub async fn doc_capture(store: &Store, path: &str, content: &str) -> Result<()> {
852 let purpose = extract_doc_comment(path, content);
853 if purpose.is_empty() {
854 return Ok(());
855 }
856
857 let file_key = format!("file:{path}");
858 let mut record = match store.get(&file_key).await? {
859 Some(r) => r,
860 None => return Ok(()),
861 };
862
863 if !matches!(
869 record.source,
870 RecordSource::StaticAnalysis | RecordSource::SessionHook
871 ) {
872 return Ok(());
873 }
874
875 if let Some(mut fr) = record.payload_as::<FileRecord>() {
876 fr.purpose = purpose.clone();
877 record.payload = serde_json::to_value(&fr).ok();
878 } else {
879 return Ok(());
880 }
881
882 let now = now_secs();
883 record.value = purpose;
884 record.source = RecordSource::SessionHook;
885 record.confidence.value = 0.65;
886 record.quality = QualityScore::doc_comment_default();
887 record.updated_at = now;
888 record.version.logical_clock += 1;
889 record.version.wall_clock = now;
890
891 if let Err(e) = store.put(&file_key, &record).await {
892 tracing::warn!(path, "doc-capture put failed: {e}");
893 }
894 Ok(())
895}
896
897pub fn extract_doc_comment(path: &str, content: &str) -> String {
900 let ext = std::path::Path::new(path)
901 .extension()
902 .and_then(|e| e.to_str())
903 .unwrap_or("");
904
905 match ext {
906 "rs" => extract_rust_module_doc(content),
907 "py" => extract_python_docstring(content),
908 "go" => extract_go_package_doc_comment(content),
909 "ts" | "tsx" | "js" | "jsx" | "mjs" | "cjs" => extract_jsdoc(content),
910 _ => String::new(),
911 }
912}
913
914fn extract_rust_module_doc(content: &str) -> String {
915 let lines: Vec<&str> = content
916 .lines()
917 .take_while(|l| l.trim_start().starts_with("//!"))
918 .map(|l| l.trim_start().trim_start_matches("//!").trim())
919 .collect();
920 lines.join(" ").trim().to_string()
921}
922
923fn extract_python_docstring(content: &str) -> String {
924 let trimmed = content.trim_start();
925 for delim in &[r#"""""#, "'''"] {
926 if let Some(rest) = trimmed.strip_prefix(delim) {
927 if let Some(end) = rest.find(delim) {
928 return rest[..end]
929 .trim()
930 .lines()
931 .next()
932 .unwrap_or("")
933 .trim()
934 .to_string();
935 }
936 }
937 }
938 String::new()
939}
940
941fn extract_go_package_doc_comment(content: &str) -> String {
942 let mut lines: Vec<String> = Vec::new();
943 for line in content.lines() {
944 let t = line.trim();
945 if t.starts_with("//") {
946 lines.push(t.trim_start_matches("//").trim().to_string());
947 } else if t.starts_with("package ") {
948 break;
949 } else if !t.is_empty() {
950 lines.clear();
951 }
952 }
953 lines.join(" ").trim().to_string()
954}
955
956fn extract_jsdoc(content: &str) -> String {
957 let trimmed = content.trim_start();
958 if let Some(rest) = trimmed.strip_prefix("/**") {
959 if let Some(end) = rest.find("*/") {
960 let text: Vec<&str> = rest[..end]
961 .lines()
962 .map(|l| l.trim().trim_start_matches('*').trim())
963 .filter(|l| !l.is_empty())
964 .collect();
965 return text.join(" ").trim().to_string();
966 }
967 }
968 String::new()
969}
970
971pub async fn promote_gotcha_candidates(store: &Store) -> Result<u32> {
974 let gotchas = store.scan_prefix("gotcha:").await?;
975 let now = now_secs();
976 let mut promoted = 0u32;
977
978 for mut record in gotchas {
979 if record.access_count < GOTCHA_PROMOTION_ACCESS_THRESHOLD {
980 continue;
981 }
982 let mut gotcha: GotchaRecord = match record.payload_as::<GotchaRecord>() {
983 Some(g) => g,
984 None => continue,
985 };
986 if gotcha.confirmed {
987 continue;
988 }
989 gotcha.confirmed = true;
990 record.payload = serde_json::to_value(&gotcha).ok();
991 record.confidence.confirmation_count += 1;
994 record.updated_at = now;
995 record.version.logical_clock += 1;
996 record.version.wall_clock = now;
997 store.put(&record.key, &record).await?;
998 promoted += 1;
999 }
1000
1001 Ok(promoted)
1002}
1003
1004pub fn format_review_date(now_secs: u64) -> String {
1007 let dt = chrono::DateTime::from_timestamp(now_secs as i64, 0).unwrap_or_else(chrono::Utc::now);
1008 dt.format("%Y-%m-%d").to_string()
1009}
1010
1011pub async fn collect_and_store_stale_reviews(
1012 store: &Store,
1013 session_value: &str,
1014 now: u64,
1015) -> Result<usize> {
1016 let session: serde_json::Value = serde_json::from_str(session_value)?;
1017 let consulted_keys = match session["consulted_keys"].as_array() {
1018 Some(arr) => arr
1019 .iter()
1020 .filter_map(|v| v.as_str().map(|s| s.to_string()))
1021 .collect::<Vec<_>>(),
1022 None => return Ok(0),
1023 };
1024 if consulted_keys.is_empty() {
1025 return Ok(0);
1026 }
1027
1028 let new_entries = collect_stale_entries(store, &consulted_keys).await?;
1029 if new_entries.is_empty() {
1030 return Ok(0);
1031 }
1032
1033 let date = format_review_date(now);
1034 let review_key = format!("analytics:stale_review_{date}");
1035 let new_count = new_entries.len();
1036
1037 let mut payload = match store.get(&review_key).await? {
1038 Some(existing) => {
1039 existing
1040 .payload_as::<StaleReviewPayload>()
1041 .unwrap_or(StaleReviewPayload {
1042 session_timestamp: now,
1043 entries: vec![],
1044 })
1045 }
1046 None => StaleReviewPayload {
1047 session_timestamp: now,
1048 entries: vec![],
1049 },
1050 };
1051
1052 let mut seen_keys = std::collections::HashSet::new();
1054 let mut merged = Vec::new();
1055 for entry in new_entries {
1056 if seen_keys.insert(entry.key.clone()) {
1057 merged.push(entry);
1058 }
1059 }
1060 for entry in payload.entries {
1061 if seen_keys.insert(entry.key.clone()) {
1062 merged.push(entry);
1063 }
1064 }
1065
1066 merged.sort_by(|a, b| {
1068 b.staleness_value
1069 .partial_cmp(&a.staleness_value)
1070 .unwrap_or(std::cmp::Ordering::Equal)
1071 });
1072 merged.truncate(MAX_STALE_REVIEW_ENTRIES);
1073
1074 payload.session_timestamp = now;
1075 payload.entries = merged;
1076
1077 let mut record = analytics_record(&review_key, String::new());
1078 record.payload = serde_json::to_value(&payload).ok();
1079 store.put(&review_key, &record).await?;
1080
1081 Ok(new_count)
1082}
1083
1084pub async fn collect_stale_entries(
1085 store: &Store,
1086 consulted_keys: &[String],
1087) -> Result<Vec<StaleReviewEntry>> {
1088 let mut entries = Vec::new();
1089
1090 for key in consulted_keys {
1091 let record = match store.get(key).await? {
1092 Some(r) => r,
1093 None => continue,
1094 };
1095
1096 if !matches!(record.lifecycle, RecordLifecycle::Active) {
1098 continue;
1099 }
1100
1101 if matches!(
1103 record.staleness.tier,
1104 StalenessTier::Liability | StalenessTier::Tombstone
1105 ) {
1106 continue;
1107 }
1108
1109 if record.staleness.value < STALE_REVIEW_MIN || record.staleness.value >= STALE_REVIEW_MAX {
1111 continue;
1112 }
1113
1114 let top_signals: Vec<String> = record
1115 .staleness
1116 .signals
1117 .iter()
1118 .take(3)
1119 .map(|s| s.to_string())
1120 .collect();
1121
1122 entries.push(StaleReviewEntry {
1123 key: key.clone(),
1124 staleness_value: record.staleness.value,
1125 tier: record.staleness.tier.clone(),
1126 last_updated: record.updated_at,
1127 signals: top_signals,
1128 });
1129 }
1130
1131 entries.sort_by(|a, b| {
1132 b.staleness_value
1133 .partial_cmp(&a.staleness_value)
1134 .unwrap_or(std::cmp::Ordering::Equal)
1135 });
1136 entries.truncate(MAX_STALE_REVIEW_ENTRIES);
1137
1138 Ok(entries)
1139}
1140
1141#[cfg(test)]
1142mod tests {
1143 use tempfile::TempDir;
1144
1145 use super::*;
1146
1147 async fn temp_store() -> (TempDir, Store) {
1148 let dir = TempDir::new().expect("tempdir");
1149 let store = Store::open(dir.path()).await.expect("open store");
1150 (dir, store)
1151 }
1152
1153 #[test]
1154 fn instructions_loaded_record_preserves_the_captured_payload() {
1155 let payload = crate::hooks::decide::InstructionsLoadedPayload {
1156 session_id: "session-123".into(),
1157 transcript_path: "/tmp/transcript.jsonl".into(),
1158 cwd: "/repo".into(),
1159 hook_event_name: "InstructionsLoaded".into(),
1160 file_path: "/repo/.claude/rules/safety.md".into(),
1161 memory_type: "Project".into(),
1162 load_reason: "session_start".into(),
1163 };
1164 let record = instructions_loaded_record("hook_event:instructions_loaded:test", &payload)
1165 .expect("record should serialize");
1166
1167 assert_eq!(record.key, "hook_event:instructions_loaded:test");
1168 assert_eq!(record.value, payload.file_path);
1169 assert_eq!(
1170 record.payload_as::<crate::hooks::decide::InstructionsLoadedPayload>(),
1171 Some(payload)
1172 );
1173 assert_eq!(
1174 crate::store::Durability::for_key(&record.key),
1175 crate::store::Durability::Eventual
1176 );
1177 }
1178
1179 #[test]
1186 fn receipt_records_the_source_it_was_minted_with() {
1187 for source in [
1188 ReceiptSource::MemGet,
1189 ReceiptSource::DbIntrospection,
1190 ReceiptSource::HookContext,
1191 ] {
1192 let staged = consultation_receipt_staged_with_fingerprint(
1193 "decision:x",
1194 None,
1195 None,
1196 Some(source),
1197 )
1198 .expect("stage receipt");
1199 let record: Record = rmp_serde::from_slice(&staged.bytes).expect("deserialize record");
1200 let receipt = record
1201 .payload_as::<ConsultationReceipt>()
1202 .expect("receipt payload");
1203 assert_eq!(
1204 receipt.source,
1205 Some(source),
1206 "minted with {source:?} but stored {:?}",
1207 receipt.source
1208 );
1209 }
1210 }
1211
1212 #[test]
1215 fn receipt_without_a_source_stays_none() {
1216 let staged = consultation_receipt_staged_with_fingerprint("decision:x", None, None, None)
1217 .expect("stage receipt");
1218 let record: Record = rmp_serde::from_slice(&staged.bytes).expect("deserialize record");
1219 let receipt = record
1220 .payload_as::<ConsultationReceipt>()
1221 .expect("receipt payload");
1222 assert_eq!(receipt.source, None);
1223 }
1224
1225 #[test]
1228 fn legacy_receipt_payload_deserializes_with_no_source() {
1229 let legacy = serde_json::json!({ "fingerprint": null, "id": "01890000-0000-7000-8000-000000000000" });
1230 let receipt: ConsultationReceipt =
1231 serde_json::from_value(legacy).expect("legacy receipt must still parse");
1232 assert_eq!(receipt.source, None);
1233 assert!(receipt.id.is_some(), "unrelated fields must survive");
1234 }
1235
1236 #[tokio::test]
1237 async fn source_aware_recent_check_requires_an_accepted_source() {
1238 let (_dir, store) = temp_store().await;
1239 let key = "decision:source-aware";
1240
1241 for source in [
1242 ReceiptSource::MemGet,
1243 ReceiptSource::DbIntrospection,
1244 ReceiptSource::HookContext,
1245 ] {
1246 let staged =
1247 consultation_receipt_staged_with_fingerprint(key, None, None, Some(source))
1248 .expect("stage receipt");
1249 let record: Record = rmp_serde::from_slice(&staged.bytes).expect("receipt record");
1250 store
1251 .put(&staged.key, &record)
1252 .await
1253 .expect("write receipt");
1254 assert!(
1255 check_consulted_recent_with_sources(&store, key, 900, None, &[source])
1256 .await
1257 .expect("check receipt"),
1258 "{source:?} should satisfy a policy accepting it"
1259 );
1260 let other = match source {
1261 ReceiptSource::MemGet => ReceiptSource::DbIntrospection,
1262 ReceiptSource::DbIntrospection => ReceiptSource::HookContext,
1263 ReceiptSource::HookContext => ReceiptSource::MemGet,
1264 };
1265 assert!(
1266 !check_consulted_recent_with_sources(&store, key, 900, None, &[other])
1267 .await
1268 .expect("check receipt"),
1269 "{source:?} must not satisfy a policy accepting only {other:?}"
1270 );
1271 }
1272
1273 let staged = consultation_receipt_staged_with_fingerprint(key, None, None, None)
1274 .expect("stage unattributed receipt");
1275 let record: Record = rmp_serde::from_slice(&staged.bytes).expect("receipt record");
1276 store
1277 .put(&staged.key, &record)
1278 .await
1279 .expect("write receipt");
1280 assert!(!check_consulted_recent_with_sources(
1281 &store,
1282 key,
1283 900,
1284 None,
1285 &[
1286 ReceiptSource::MemGet,
1287 ReceiptSource::DbIntrospection,
1288 ReceiptSource::HookContext
1289 ]
1290 )
1291 .await
1292 .expect("check unattributed receipt"));
1293
1294 let mut legacy = session_record(&format!("session:consulted:{key}"), String::new());
1295 legacy.payload = Some(serde_json::json!({
1296 "fingerprint": null,
1297 "id": "01890000-0000-7000-8000-000000000000"
1298 }));
1299 store
1300 .put(&legacy.key, &legacy)
1301 .await
1302 .expect("write legacy receipt");
1303 assert!(!check_consulted_recent_with_sources(
1304 &store,
1305 key,
1306 900,
1307 None,
1308 &[ReceiptSource::MemGet]
1309 )
1310 .await
1311 .expect("check legacy receipt"));
1312 }
1313
1314 fn temp_repo_with_commit(rel_path: &str) -> (TempDir, String) {
1318 let dir = TempDir::new().expect("tempdir");
1319 let repo = git2::Repository::init(dir.path()).expect("git init");
1320
1321 let full = dir.path().join(rel_path);
1322 std::fs::create_dir_all(full.parent().expect("parent")).expect("mkdir");
1323 std::fs::write(&full, "fn main() {}").expect("write");
1324
1325 let mut index = repo.index().expect("index");
1326 index.add_path(Path::new(rel_path)).expect("add");
1327 index.write().expect("index write");
1328 let tree = repo
1329 .find_tree(index.write_tree().expect("write tree"))
1330 .expect("tree");
1331 let sig = git2::Signature::now("mati test", "test@example.invalid").expect("sig");
1332 let oid = repo
1333 .commit(Some("HEAD"), &sig, &sig, "seed", &tree, &[])
1334 .expect("commit");
1335
1336 (dir, oid.to_string())
1337 }
1338
1339 fn file_record_at(key: &str, updated_at: u64) -> Record {
1340 Record {
1341 key: key.to_string(),
1342 value: "seed".to_string(),
1343 category: Category::File,
1344 priority: Priority::Normal,
1345 tags: vec![],
1346 created_at: updated_at,
1347 updated_at,
1348 ref_url: None,
1349 staleness: StalenessScore::fresh(),
1350 confidence: ConfidenceScore::for_new_record(&RecordSource::StaticAnalysis),
1351 quality: QualityScore::layer0_default(),
1352 source: RecordSource::StaticAnalysis,
1353 payload: None,
1354 version: RecordVersion {
1355 device_id: uuid::Uuid::new_v4(),
1356 logical_clock: 1,
1357 wall_clock: updated_at,
1358 },
1359 lifecycle: RecordLifecycle::Active,
1360 access_count: 0,
1361 last_accessed: 0,
1362 gap_analysis_score: 0.0,
1363 }
1364 }
1365
1366 #[tokio::test]
1370 async fn session_harvest_runs_git_staleness_analysis() {
1371 let (repo_dir, head_sha) = temp_repo_with_commit("src/seed.rs");
1372 let (_dir, store) = temp_store().await;
1373
1374 let record = file_record_at("file:src/seed.rs", now_secs() - (60 * 86_400));
1375 assert!(record.staleness.last_record_sha.is_empty());
1376 store.put(&record.key, &record).await.expect("put");
1377
1378 session_harvest(&store, repo_dir.path())
1379 .await
1380 .expect("harvest");
1381
1382 let after = store
1383 .get("file:src/seed.rs")
1384 .await
1385 .expect("get")
1386 .expect("record survives harvest");
1387 assert_eq!(after.staleness.last_record_sha, head_sha);
1388 assert_ne!(after.staleness.tier, StalenessTier::Tombstone);
1389
1390 store.close().await.expect("close");
1391 }
1392
1393 #[tokio::test]
1396 async fn harvest_survives_a_staleness_failure() {
1397 let (_dir, store) = temp_store().await;
1398
1399 let record = file_record_at("file:src/seed.rs", now_secs() - (60 * 86_400));
1400 store.put(&record.key, &record).await.expect("put");
1401 let mut stage = file_record_at("stage:current", now_secs());
1402 stage.category = Category::Stage;
1403 store.put("stage:current", &stage).await.expect("put stage");
1404 session_flush(&store).await.expect("flush");
1405
1406 let nowhere = TempDir::new().expect("tempdir");
1408 session_harvest(&store, nowhere.path())
1409 .await
1410 .expect("harvest must not fail when staleness cannot run");
1411
1412 let sessions = store.scan_keys("session:").await.expect("scan");
1413 assert!(
1414 sessions.iter().any(|k| k != "session:current"),
1415 "session was archived: {sessions:?}"
1416 );
1417 let stage = store
1418 .get("stage:current")
1419 .await
1420 .expect("get")
1421 .expect("stage record");
1422 assert!(stage.value.contains("last_session:"));
1423
1424 let after = store
1426 .get("file:src/seed.rs")
1427 .await
1428 .expect("get")
1429 .expect("record");
1430 assert_ne!(after.staleness.tier, StalenessTier::Tombstone);
1431
1432 store.close().await.expect("close");
1433 }
1434
1435 #[tokio::test]
1436 async fn write_subagent_summary_writes_latest_key() {
1437 let (_dir, store) = temp_store().await;
1438
1439 write_subagent_summary(
1440 &store,
1441 " Read config.rs; the timeout is in millis. ",
1442 Some("agent-abc"),
1443 Some("general-purpose"),
1444 Some("sess-1"),
1445 Some("/t/agent-abc.jsonl"),
1446 )
1447 .await
1448 .expect("write summary");
1449
1450 let rec = store
1451 .get(SUBAGENT_SUMMARY_KEY)
1452 .await
1453 .expect("get")
1454 .expect("summary record exists");
1455 assert_eq!(rec.value, "Read config.rs; the timeout is in millis.");
1456 let payload = rec.payload.expect("payload");
1457 assert_eq!(payload["agent_id"], "agent-abc");
1458 assert_eq!(payload["agent_type"], "general-purpose");
1459 assert_eq!(payload["session_id"], "sess-1");
1460
1461 store.close().await.expect("close");
1462 }
1463
1464 #[tokio::test]
1465 async fn write_subagent_summary_empty_is_noop() {
1466 let (_dir, store) = temp_store().await;
1467
1468 write_subagent_summary(&store, " \n ", None, None, None, None)
1469 .await
1470 .expect("empty summary is ok");
1471
1472 assert!(
1473 store
1474 .get(SUBAGENT_SUMMARY_KEY)
1475 .await
1476 .expect("get")
1477 .is_none(),
1478 "empty summary must not write a record"
1479 );
1480
1481 store.close().await.expect("close");
1482 }
1483
1484 #[tokio::test]
1485 async fn write_subagent_summary_truncates_long_input() {
1486 let (_dir, store) = temp_store().await;
1487
1488 let long = "x".repeat(SUBAGENT_SUMMARY_MAX + 500);
1489 write_subagent_summary(&store, &long, None, None, None, None)
1490 .await
1491 .expect("write");
1492
1493 let rec = store
1494 .get(SUBAGENT_SUMMARY_KEY)
1495 .await
1496 .expect("get")
1497 .expect("record");
1498 assert_eq!(rec.value.chars().count(), SUBAGENT_SUMMARY_MAX + 1);
1500 assert!(rec.value.ends_with('…'));
1501
1502 store.close().await.expect("close");
1503 }
1504
1505 #[tokio::test]
1506 async fn session_harvest_clears_subagent_summary() {
1507 let (_dir, store) = temp_store().await;
1508
1509 write_subagent_summary(&store, "did a thing", None, None, None, None)
1510 .await
1511 .expect("write");
1512 assert!(store
1513 .get(SUBAGENT_SUMMARY_KEY)
1514 .await
1515 .expect("get")
1516 .is_some());
1517
1518 let nowhere = TempDir::new().expect("tempdir");
1520 session_harvest(&store, nowhere.path())
1521 .await
1522 .expect("harvest");
1523
1524 assert!(
1525 store
1526 .get(SUBAGENT_SUMMARY_KEY)
1527 .await
1528 .expect("get")
1529 .is_none(),
1530 "harvest must clear the subagent summary"
1531 );
1532
1533 store.close().await.expect("close");
1534 }
1535
1536 #[tokio::test]
1541 async fn harvest_survives_a_junk_staleness_cursor() {
1542 let (repo_dir, head_sha) = temp_repo_with_commit("src/seed.rs");
1543 let (_dir, store) = temp_store().await;
1544
1545 let record = file_record_at("file:src/seed.rs", now_secs() - (60 * 86_400));
1546 store.put(&record.key, &record).await.expect("put");
1547 session_flush(&store).await.expect("flush");
1548
1549 let mut junk = file_record_at("health:staleness_cursor", now_secs());
1552 junk.payload = Some(serde_json::json!({ "after": "retired_namespace:whatever" }));
1553 store
1554 .put("health:staleness_cursor", &junk)
1555 .await
1556 .expect("put cursor");
1557
1558 session_harvest(&store, repo_dir.path())
1559 .await
1560 .expect("harvest must survive a cursor it cannot place");
1561
1562 let after = store
1563 .get("file:src/seed.rs")
1564 .await
1565 .expect("get")
1566 .expect("record");
1567 assert_eq!(
1568 after.staleness.last_record_sha, head_sha,
1569 "the sweep restarted instead of skipping past the junk cursor"
1570 );
1571 let sessions = store.scan_keys("session:").await.expect("scan");
1572 assert!(sessions.iter().any(|k| k != "session:current"));
1573
1574 store.close().await.expect("close");
1575 }
1576
1577 #[tokio::test]
1578 async fn log_bootstrap_creates_daily_aggregate() {
1579 let (_dir, store) = temp_store().await;
1580
1581 log_bootstrap(&store, "__bootstrap__")
1582 .await
1583 .expect("log bootstrap");
1584
1585 let key = today_key("analytics:bootstrap_");
1586 let record = store
1587 .get(&key)
1588 .await
1589 .expect("get bootstrap aggregate")
1590 .expect("bootstrap record exists");
1591 let agg = record.payload_as::<DailyAgg>().expect("daily agg payload");
1592 assert_eq!(agg.count, 1);
1593 assert_eq!(agg.keys, vec!["__bootstrap__".to_string()]);
1594 }
1595
1596 #[tokio::test]
1597 async fn shadow_observation_caps_are_isolated_and_keep_recent_entries() {
1598 let (_dir, store) = temp_store().await;
1599 let action = crate::hooks::decide::Action {
1600 tool: "db_client".into(),
1601 target_path: None,
1602 host: Some("db.example".into()),
1603 argv: vec![],
1604 files: vec![],
1605 };
1606 for index in 0..=MAX_SHADOW_OBSERVATIONS {
1607 let mut action = action.clone();
1608 action.argv = vec![index.to_string()];
1609 record_shadow_observation(
1610 &store,
1611 "policy:noisy",
1612 &action,
1613 crate::hooks::decide::ShadowOutcome::Block,
1614 )
1615 .await
1616 .unwrap();
1617 }
1618 record_shadow_observation(
1619 &store,
1620 "policy:quiet",
1621 &action,
1622 crate::hooks::decide::ShadowOutcome::Steer,
1623 )
1624 .await
1625 .unwrap();
1626
1627 let record = store.get(&shadow_observation_key()).await.unwrap().unwrap();
1628 let agg = record.payload_as::<ShadowObservationAgg>().unwrap();
1629 let noisy = &agg.policies["policy:noisy"];
1630 assert_eq!(noisy.count, (MAX_SHADOW_OBSERVATIONS + 1) as u64);
1631 assert_eq!(noisy.observations.len(), MAX_SHADOW_OBSERVATIONS);
1632 assert_eq!(noisy.observations[0].action.argv, vec!["1"]);
1633 assert_eq!(agg.policies["policy:quiet"].count, 1);
1634 assert_eq!(agg.policies["policy:quiet"].observations.len(), 1);
1635 }
1636
1637 #[tokio::test]
1638 async fn check_consulted_recent_uses_receipt_ttl() {
1639 let (_dir, store) = temp_store().await;
1640 let key = "file:src/main.rs";
1641
1642 assert!(!check_consulted_recent(&store, key, 900, None)
1643 .await
1644 .expect("no receipt yet"));
1645
1646 log_hit(&store, key).await.expect("log consultation hit");
1647
1648 assert!(check_consulted_recent(&store, key, 900, None)
1649 .await
1650 .expect("fresh receipt should be valid"));
1651 }
1652
1653 #[tokio::test]
1654 async fn fingerprinted_receipt_invalidates_after_content_drift() {
1655 let (_dir, store) = temp_store().await;
1656 let key = "schema:orders";
1657 store
1658 .put(key, &session_record(key, "orders v1".into()))
1659 .await
1660 .unwrap();
1661 log_hit(&store, key).await.unwrap();
1662
1663 let receipt = store.get(&receipt_key(key, None)).await.unwrap().unwrap();
1664 assert!(receipt
1665 .payload_as::<ConsultationReceipt>()
1666 .and_then(|payload| payload.fingerprint)
1667 .is_some());
1668 assert!(check_consulted_recent_fingerprinted(&store, key, 900, None)
1669 .await
1670 .unwrap());
1671
1672 let mut changed = store.get(key).await.unwrap().unwrap();
1673 changed.value = "orders v2".into();
1674 store.put(key, &changed).await.unwrap();
1675 assert!(
1676 !check_consulted_recent_fingerprinted(&store, key, 900, None)
1677 .await
1678 .unwrap()
1679 );
1680 }
1681
1682 #[tokio::test]
1683 async fn fingerprinted_check_rejects_missing_receipt() {
1684 let (_dir, store) = temp_store().await;
1685
1686 assert!(
1687 !check_consulted_recent_fingerprinted(&store, "schema:orders", 900, None)
1688 .await
1689 .expect("missing receipt is a legitimate non-satisfaction")
1690 );
1691 }
1692
1693 #[tokio::test]
1694 async fn fingerprinted_check_rejects_legacy_or_introspection_receipts() {
1695 let (_dir, store) = temp_store().await;
1696 let key = "schema:orders";
1697 store
1698 .put(key, &session_record(key, "orders v1".into()))
1699 .await
1700 .unwrap();
1701 let staged = consultation_receipt_staged(key, None).unwrap();
1702 let (receipt_key_value, receipt_bytes) = (staged.key, staged.bytes);
1703 store
1704 .transact_sessions_raw(&[(&receipt_key_value, &receipt_bytes)])
1705 .await
1706 .unwrap();
1707 assert!(
1708 !check_consulted_recent_fingerprinted(&store, key, 900, None)
1709 .await
1710 .unwrap()
1711 );
1712 }
1713
1714 #[tokio::test]
1715 async fn fingerprinted_check_still_enforces_ttl() {
1716 let (_dir, store) = temp_store().await;
1717 let key = "schema:orders";
1718 store
1719 .put(key, &session_record(key, "orders v1".into()))
1720 .await
1721 .unwrap();
1722 log_hit(&store, key).await.unwrap();
1723 let receipt_key_value = receipt_key(key, None);
1724 let mut receipt = store.get(&receipt_key_value).await.unwrap().unwrap();
1725 receipt.updated_at = 0;
1726 store.put(&receipt_key_value, &receipt).await.unwrap();
1727 assert!(
1728 !check_consulted_recent_fingerprinted(&store, key, 900, None)
1729 .await
1730 .unwrap()
1731 );
1732 }
1733
1734 #[tokio::test]
1735 async fn fingerprinted_check_rejects_deleted_required_record() {
1736 let (_dir, store) = temp_store().await;
1737 let key = "schema:orders";
1738 store
1739 .put(key, &session_record(key, "orders v1".into()))
1740 .await
1741 .unwrap();
1742 log_hit(&store, key).await.unwrap();
1743 store.delete(key).await.unwrap();
1744
1745 assert!(
1746 !check_consulted_recent_fingerprinted(&store, key, 900, None)
1747 .await
1748 .expect("deleted record is a legitimate non-satisfaction")
1749 );
1750 }
1751
1752 #[tokio::test]
1753 async fn consult_receipt_is_actor_scoped_when_actor_present() {
1754 let (_dir, store) = temp_store().await;
1755
1756 let staged_k = consultation_receipt_staged("file:x", Some("agentA")).unwrap();
1758 let (k, v) = (staged_k.key, staged_k.bytes);
1759 store.transact_sessions_raw(&[(&k, &v)]).await.unwrap();
1760
1761 let keys = store.scan_keys("session:consulted:").await.unwrap();
1762 assert!(
1763 keys.iter().any(|k| k == "session:consulted:agentA:file:x"),
1764 "actor-scoped key must be present, got: {keys:?}"
1765 );
1766 assert!(
1767 !keys.iter().any(|k| k == "session:consulted:file:x"),
1768 "global key must NOT be written by actor-scoped call, got: {keys:?}"
1769 );
1770
1771 let staged_k2 = consultation_receipt_staged("file:x", None).unwrap();
1773 let (k2, v2) = (staged_k2.key, staged_k2.bytes);
1774 store.transact_sessions_raw(&[(&k2, &v2)]).await.unwrap();
1775
1776 let keys2 = store.scan_keys("session:consulted:").await.unwrap();
1777 assert!(
1778 keys2.iter().any(|k| k == "session:consulted:file:x"),
1779 "global key must be present with actor=None, got: {keys2:?}"
1780 );
1781 }
1782
1783 #[tokio::test]
1784 async fn gate_requires_actor_scoped_receipt_for_subagent() {
1785 let (_dir, store) = temp_store().await;
1786
1787 let staged_k = consultation_receipt_staged("file:x", Some("agentA")).unwrap();
1789 let (k, v) = (staged_k.key, staged_k.bytes);
1790 store.transact_sessions_raw(&[(&k, &v)]).await.unwrap();
1791
1792 assert!(
1794 check_consulted_recent(&store, "file:x", 900, Some("agentA"))
1795 .await
1796 .expect("agentA receipt lookup"),
1797 "agentA should see its own actor-scoped receipt"
1798 );
1799
1800 assert!(
1802 !check_consulted_recent(&store, "file:x", 900, Some("agentB"))
1803 .await
1804 .expect("agentB receipt lookup"),
1805 "agentB must NOT ride agentA's receipt"
1806 );
1807
1808 let staged_k2 = consultation_receipt_staged("file:y", None).unwrap();
1810 let (k2, v2) = (staged_k2.key, staged_k2.bytes);
1811 store.transact_sessions_raw(&[(&k2, &v2)]).await.unwrap();
1812
1813 assert!(
1815 check_consulted_recent(&store, "file:y", 900, None)
1816 .await
1817 .expect("global receipt lookup"),
1818 "main thread must still see the global receipt"
1819 );
1820
1821 assert!(
1823 !check_consulted_recent(&store, "file:y", 900, Some("agentA"))
1824 .await
1825 .expect("agentA vs global receipt lookup"),
1826 "subagent must NOT ride the global main-thread receipt"
1827 );
1828 }
1829
1830 #[tokio::test]
1831 async fn session_clear_consults_deletes_all_receipts() {
1832 let (_dir, store) = temp_store().await;
1833 let key1 = "file:src/main.rs";
1834 let key2 = "file:src/lib.rs";
1835
1836 log_hit(&store, key1).await.expect("log first hit");
1837 log_hit(&store, key2).await.expect("log second hit");
1838
1839 let before = store
1841 .scan_keys("session:consulted:")
1842 .await
1843 .expect("scan before");
1844 assert_eq!(before.len(), 2, "expected two receipts before clear");
1845
1846 session_clear_consults(&store)
1847 .await
1848 .expect("clear_consults should succeed");
1849
1850 let after = store
1851 .scan_keys("session:consulted:")
1852 .await
1853 .expect("scan after");
1854 assert!(after.is_empty(), "all receipts should be gone after clear");
1855 }
1856
1857 #[tokio::test]
1866 async fn doc_capture_refreshes_a_prior_session_hook_capture() {
1867 let (_dir, store) = temp_store().await;
1868
1869 let mut record = file_record_at("file:src/lib.rs", 100);
1870 let fr = FileRecord::layer0_stub(
1871 "src/lib.rs",
1872 vec![],
1873 vec![],
1874 vec![],
1875 0,
1876 0,
1877 0,
1878 None,
1879 false,
1880 0,
1881 1,
1882 );
1883 record.payload = serde_json::to_value(&fr).ok();
1884 store.put("file:src/lib.rs", &record).await.expect("seed");
1885
1886 doc_capture(
1887 &store,
1888 "src/lib.rs",
1889 "//! First purpose.
1890fn main() {}",
1891 )
1892 .await
1893 .expect("first capture");
1894 let after_first = store
1895 .get("file:src/lib.rs")
1896 .await
1897 .expect("get")
1898 .expect("record exists");
1899 assert_eq!(after_first.source, RecordSource::SessionHook);
1900 let fr1: FileRecord = after_first.payload_as().expect("payload");
1901 assert_eq!(fr1.purpose, "First purpose.");
1902
1903 doc_capture(
1904 &store,
1905 "src/lib.rs",
1906 "//! Updated purpose.
1907fn main() {}",
1908 )
1909 .await
1910 .expect("second capture");
1911 let after_second = store
1912 .get("file:src/lib.rs")
1913 .await
1914 .expect("get")
1915 .expect("record exists");
1916 assert_eq!(after_second.source, RecordSource::SessionHook);
1917 let fr2: FileRecord = after_second.payload_as().expect("payload");
1918 assert_eq!(
1919 fr2.purpose, "Updated purpose.",
1920 "doc-capture must refresh a SessionHook-sourced record, not ratchet shut"
1921 );
1922 }
1923
1924 #[tokio::test]
1927 async fn doc_capture_never_overwrites_developer_manual() {
1928 let (_dir, store) = temp_store().await;
1929
1930 let mut record = file_record_at("file:src/manual.rs", 100);
1931 record.source = RecordSource::DeveloperManual;
1932 let fr = FileRecord::layer0_stub(
1933 "src/manual.rs",
1934 vec![],
1935 vec![],
1936 vec![],
1937 0,
1938 0,
1939 0,
1940 None,
1941 false,
1942 0,
1943 1,
1944 );
1945 record.payload = serde_json::to_value(&fr).ok();
1946 store
1947 .put("file:src/manual.rs", &record)
1948 .await
1949 .expect("seed");
1950
1951 doc_capture(
1952 &store,
1953 "src/manual.rs",
1954 "//! Should not apply.
1955fn main() {}",
1956 )
1957 .await
1958 .expect("capture");
1959 let after = store
1960 .get("file:src/manual.rs")
1961 .await
1962 .expect("get")
1963 .expect("record exists");
1964 assert_eq!(after.source, RecordSource::DeveloperManual);
1965 let fr_after: FileRecord = after.payload_as().expect("payload");
1966 assert_eq!(
1967 fr_after.purpose, "",
1968 "developer-curated purpose must not be overwritten by doc-capture"
1969 );
1970 }
1971
1972 fn run_git(dir: &Path, args: &[&str]) {
1975 let status = std::process::Command::new("git")
1976 .args(args)
1977 .current_dir(dir)
1978 .status()
1979 .expect("run git");
1980 assert!(status.success(), "git {args:?} failed in {dir:?}");
1981 }
1982
1983 fn temp_repo_for_worktrees() -> TempDir {
1985 let dir = TempDir::new().expect("tempdir");
1986 run_git(dir.path(), &["init", "-q"]);
1987 run_git(dir.path(), &["config", "user.email", "t@t.com"]);
1988 run_git(dir.path(), &["config", "user.name", "t"]);
1989 std::fs::write(dir.path().join("file.txt"), "hello").expect("write");
1990 run_git(dir.path(), &["add", "-A"]);
1991 run_git(dir.path(), &["commit", "-q", "-m", "init"]);
1992 dir
1993 }
1994
1995 #[test]
2000 fn worktree_scope_tag_differs_for_sibling_worktrees() {
2001 let main = temp_repo_for_worktrees();
2002 let sibling_parent = TempDir::new().expect("tempdir");
2003 let wt_path = sibling_parent.path().join("wt");
2004 run_git(
2005 main.path(),
2006 &[
2007 "worktree",
2008 "add",
2009 wt_path.to_str().unwrap(),
2010 "-b",
2011 "wt-branch",
2012 ],
2013 );
2014 let main_tag = worktree_scope_tag(main.path()).expect("main tag");
2015 let wt_tag = worktree_scope_tag(&wt_path).expect("worktree tag");
2016 assert_ne!(
2017 main_tag, wt_tag,
2018 "a sibling worktree must not share the main checkout's scope"
2019 );
2020 }
2021
2022 #[test]
2029 fn worktree_scope_tag_differs_for_nested_worktree() {
2030 let main = temp_repo_for_worktrees();
2031 let nested = main.path().join(".worktrees").join("wt");
2032 run_git(
2033 main.path(),
2034 &[
2035 "worktree",
2036 "add",
2037 nested.to_str().unwrap(),
2038 "-b",
2039 "nested-branch",
2040 ],
2041 );
2042 let main_tag = worktree_scope_tag(main.path()).expect("main tag");
2043 let nested_tag = worktree_scope_tag(&nested).expect("nested tag");
2044 assert_ne!(
2045 main_tag, nested_tag,
2046 "a nested worktree must not share the main checkout's scope"
2047 );
2048 }
2049
2050 #[test]
2051 fn worktree_scope_tag_is_stable_for_the_same_worktree() {
2052 let main = temp_repo_for_worktrees();
2053 let a = worktree_scope_tag(main.path());
2054 let b = worktree_scope_tag(main.path());
2055 assert!(a.is_some());
2056 assert_eq!(a, b);
2057 }
2058
2059 #[test]
2060 fn worktree_scope_tag_is_none_outside_a_git_repo() {
2061 let dir = TempDir::new().expect("tempdir");
2062 assert_eq!(worktree_scope_tag(dir.path()), None);
2063 }
2064
2065 #[test]
2066 fn combined_actor_scope_precedence() {
2067 assert_eq!(combined_actor_scope(None, None), None);
2068 assert_eq!(
2069 combined_actor_scope(Some("wtA"), None),
2070 Some("wtA".to_string())
2071 );
2072 assert_eq!(
2073 combined_actor_scope(None, Some("agent-a")),
2074 Some("agent-a".to_string())
2075 );
2076 assert_eq!(
2077 combined_actor_scope(Some("wtA"), Some("agent-a")),
2078 Some("wtA:agent-a".to_string())
2079 );
2080 }
2081}