Skip to main content

mati_core/store/
session.rs

1//! Analytics and session lifecycle functions for the hook pipeline.
2//!
3//! These functions are called from two paths:
4//! - `cli/hooks.rs` fallback (when daemon is not running, direct store open)
5//! - `mcp/server.rs` daemon socket (when MCP server holds the exclusive lock)
6//!
7//! Having them here avoids code duplication and ensures both paths are
8//! behaviourally identical.
9
10use 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
24// ── Internal helpers ──────────────────────────────────────────────────────────
25
26pub 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
72/// Key holding the most recent finished subagent's summary. A single,
73/// overwritten key: O(1) for `recent_session` to read, self-bounding (never
74/// grows), and cleared at session harvest so it stays scoped to one session.
75pub const SUBAGENT_SUMMARY_KEY: &str = "session:summary:latest";
76
77/// Max stored characters of a subagent summary. Bounds what `recent_session`
78/// can inject into the bootstrap packet.
79const 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
90/// Record a finished subagent's summary, overwriting [`SUBAGENT_SUMMARY_KEY`].
91/// Empty summaries are a no-op. Best-effort — the SubagentStop hook fails open.
92pub 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
114/// Build the eventual record for one Claude Code `InstructionsLoaded` event.
115pub 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
124/// Persist one Claude Code `InstructionsLoaded` payload.
125///
126/// The `hook_event:` key namespace routes through `Store::put` to the
127/// Eventual/session tree. Recording is best-effort at the hook adapter.
128pub 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/// Daily aggregation record value.
139#[derive(serde::Serialize, serde::Deserialize, Debug)]
140pub struct DailyAgg {
141    pub count: u64,
142    pub keys: Vec<String>,
143    /// Per-target counts for surfaces that need activity by policy, while
144    /// preserving the original aggregate count and bounded key set.
145    #[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
175/// Record a bounded daily aggregate for a shadow policy that would have blocked.
176/// This deliberately stays in the eventual session/analytics tree: it is a
177/// review signal, not an enforcement fact and not part of the hash chain.
178pub 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
209/// Minimum staleness value for stale review inclusion.
210const STALE_REVIEW_MIN: f32 = 0.4;
211/// Maximum staleness value for stale review inclusion (Liability and above excluded).
212const STALE_REVIEW_MAX: f32 = 0.7;
213/// Default TTL for recent consultation receipts (15 minutes).
214pub const CONSULTED_RECENT_TTL_SECS: u64 = 900;
215/// Maximum entries in a single daily stale review record.
216pub const MAX_STALE_REVIEW_ENTRIES: usize = 20;
217/// Minimum access count before an unconfirmed gotcha is auto-promoted.
218pub 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    /// Identifies this mint in the enforcement chain: the `ReceiptMinted` event
225    /// carries it, and so does the `AllowAfterReceipt` this receipt later
226    /// authorizes. `None` on receipts written before the field existed.
227    #[serde(default)]
228    pub id: Option<String>,
229    /// How the consultation happened. Policy `requires.via` names the sources a
230    /// policy accepts, and satisfaction compares this value with that list.
231    ///
232    /// `None` on receipts written before the field existed, and on mints whose
233    /// source is not attributable: `mati log-hit` and the `StoreProxy` path.
234    /// Those are left unset rather than guessed — an invented provenance is
235    /// worse than an absent one.
236    #[serde(default)]
237    pub source: Option<ReceiptSource>,
238}
239
240/// A consultation receipt staged for write, carrying the id that links it to
241/// the enforcement events it authorizes.
242pub struct StagedReceipt {
243    pub key: String,
244    pub bytes: Vec<u8>,
245    pub id: String,
246}
247
248/// Hash the stable, knowledge-bearing content of a record. Mutable access and
249/// timestamp metadata are excluded so minting a receipt does not invalidate
250/// itself when `log_hit` updates access tracking.
251pub 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
273// ── Worktree-scoped receipts ─────────────────────────────────────────────────
274
275/// Identify the git worktree at `cwd` for receipt scoping.
276///
277/// Git worktrees share history (and, by extension, mati's slug and store)
278/// but not working-tree content: the same file path can hold different bytes
279/// in two worktrees of the same repo. A consultation receipt minted while
280/// operating in one worktree must not satisfy a gate checked from another.
281/// `git2::Repository::discover` resolves each worktree's own `workdir()`
282/// correctly for both sibling and nested worktree layouts — unlike a lexical
283/// `.git`-file check, which mistakes a nested worktree's `.git` pointer file
284/// for "no boundary here" and walks up into the main checkout's real `.git`
285/// directory. `None` when no git repo is discoverable; the caller then falls
286/// back to whatever scope `agent_id` alone provides.
287pub fn worktree_scope_tag(cwd: &Path) -> Option<String> {
288    worktree_scope_tag_for(&RepoIdent::discover(cwd))
289}
290
291/// [`worktree_scope_tag`] for a caller that already discovered a
292/// [`RepoIdent`] this invocation — avoids a second `git2::Repository::discover`
293/// call for the same repo (see `cli::hook_decide::entry::run_inner`).
294pub 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
301/// Combine the worktree tag with an optional subagent id into the single
302/// opaque scope string receipts key on. `None` only when neither is known,
303/// preserving the pre-existing unscoped global receipt outside any git repo.
304pub 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
349/// Compute the daily aggregation upsert WITHOUT persisting.
350///
351/// Returns `(key, serialized_record_bytes)` for staging into a
352/// `transact_sessions_raw` call. The caller commits this alongside
353/// other writes (e.g., audit) in one atomic transaction.
354pub 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
403/// Compute the consultation receipt record WITHOUT persisting.
404///
405/// When `actor` is `Some`, writes an actor-scoped key `session:consulted:<actor>:<key>`
406/// alongside the global key path. Pass `None` for all existing callers (global).
407pub 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
434/// Id of the consultation receipt in force for `key` at this actor's scope.
435///
436/// `None` when no receipt exists or it was minted before receipt ids. Used to
437/// stamp `AllowAfterReceipt` with the receipt that authorized it — never to
438/// decide anything, so a missing id costs the audit link, not the gate.
439pub 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
464/// Compute the session:current flush record WITHOUT persisting.
465///
466/// Returns `(key, serialized_record_bytes)` for staging.
467pub 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
489// ── log_hit ───────────────────────────────────────────────────────────────────
490
491/// Record a cache hit: write consulted marker, bump access_count, update daily agg.
492pub async fn log_hit(store: &Store, key: &str) -> Result<()> {
493    let now = now_secs();
494
495    // 1. Daily hit aggregation
496    let agg_key = today_key("analytics:hit_");
497    upsert_daily_agg(store, &agg_key, key).await?;
498
499    // 2. Mark as consulted for session tracking. No source: this is the direct
500    // store path behind `StoreProxy::log_hit`, reached from CLI commands whose
501    // consultation is neither a `mem_get` nor an introspection.
502    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    // 3. Bump access_count and last_accessed on the target record
508    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    // 4. Best-effort enforcement event: ReceiptMinted.
515    //
516    // Mirrors the socket-mode path in `dispatch_v2::ConsultationHit` so the
517    // direct-mode CLI path (`mati explain` without a daemon, or any code
518    // calling `session::log_hit` against an open Store) produces the same
519    // `receipt_minted` row in `mati history --enforcement`. Without this
520    // parity, the enforcement audit log has gaps depending on whether the
521    // mint happened over socket or direct mode.
522    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
537// ── log_miss ──────────────────────────────────────────────────────────────────
538
539/// Record a cache miss: update daily miss aggregation.
540pub 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
545// ── log_compliance_miss ───────────────────────────────────────────────────────
546
547/// Record a compliance miss: file read without prior mati consultation.
548pub 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
553/// Record a compliance hit: file access allowed because a valid consultation
554/// receipt existed. Platform-neutral — incremented for both Claude pre-read
555/// `AlreadyConsulted` allow and Codex post-bash confirmed consultation.
556pub 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
561/// Record a Codex shell compliance miss: Bash file inspection without consultation.
562pub 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
567/// Record a Codex prompt nudge: prompt indicated code work before clear consultation.
568pub 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
573/// Record a bootstrap event. Used to measure Codex/agent bootstrap adoption.
574pub 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
579// ── check_consulted ───────────────────────────────────────────────────────────
580
581/// Return true if the consulted marker exists (set by `log_hit` / capture hook).
582///
583/// When `actor` is `Some(id)`, reads the actor-scoped key
584/// `session:consulted:<id>:<key>` (subagent path); `None` reads the global key
585/// `session:consulted:<key>` (main-thread path — unchanged).
586pub 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
591/// Return true if the consulted marker exists and is newer than `ttl_secs`.
592///
593/// When `actor` is `Some(id)`, reads the actor-scoped key
594/// `session:consulted:<id>:<key>` (subagent enforcement path).
595/// When `actor` is `None`, reads the global key `session:consulted:<key>`
596/// (main-thread path — unchanged behaviour).
597pub 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
611/// Return true only when a recent receipt records one of the policy's accepted
612/// consultation sources. A missing source is deliberately not accepted: it is
613/// legacy or unattributed evidence, not evidence for a named channel.
614pub 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
638/// Return true only when a recent receipt carries the current record's
639/// fingerprint. Store faults propagate as errors so the caller can fail open;
640/// swallowing them into `false` would fail closed and deny on a mati outage.
641pub 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    // A store read fault must remain an Err so hook callers fail open, never Ok(false).
661    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
668/// Return true only when a recent, fingerprint-valid receipt records one of
669/// the policy's accepted consultation sources.
670pub 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    // A store read fault must remain an Err so hook callers fail open, never
699    // Ok(false).
700    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
707// ── session_flush ─────────────────────────────────────────────────────────────
708
709/// Collect all consulted markers into `session:current` for harvest.
710pub 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
733/// Delete all consult receipts (`session:consulted:*`) from the store.
734///
735/// Shared by `session_clear_consults` (PostCompact) and the end-of-session
736/// `session_harvest` cleanup. Propagates store errors; the daemon-startup
737/// stale-marker sweep keeps its own fail-soft loop.
738async 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
746/// Clear all consult receipts for the session.
747///
748/// Used by the PostCompact hook: compaction wipes the agent's memory of consulted
749/// gotchas, but receipts are time-based and survive, so PreToolUse would not
750/// re-block. Clearing them forces a fresh mem_get on next access.
751pub async fn session_clear_consults(store: &Store) -> Result<()> {
752    delete_all_receipts(store).await
753}
754
755// ── session_harvest ───────────────────────────────────────────────────────────
756
757/// Archive session, run staleness analysis, auto-promote gotchas.
758///
759/// `repo_root` is the project root; the git root is discovered upward from it.
760/// Called from both daemon socket handlers — `mcp::server::socket_dispatch` and
761/// `mcp::dispatch_v2::session::dispatch_session_side` — on SessionEnd.
762///
763/// Every step is fail-open: staleness runs on the SessionEnd path, and a git
764/// fault must not cost the session its archive, its promotions, or its receipt
765/// cleanup.
766pub async fn session_harvest(store: &Store, repo_root: &Path) -> Result<()> {
767    let now = now_secs();
768
769    // M-12-D: promote gotcha candidates before archiving
770    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    // M-13-A: run full staleness analysis. Bounded by ANALYZE_TIME_BUDGET_MS.
777    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    // Drop the latest subagent summary — scoped to the session that just ended.
792    // Above the session:current guard: a session can produce subagent summaries
793    // without a main-agent flush, and the summary must not leak to the next one.
794    let _ = store.delete(SUBAGENT_SUMMARY_KEY).await;
795
796    // Read session:current (written by session-flush)
797    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    // M-13-C: collect and store stale reviews for consulted keys
808    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    // Write permanent session record
815    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    // Clean up session:consulted:* markers
821    delete_all_receipts(store).await?;
822
823    // Update stage:current with last session timestamp
824    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
845// ── doc_capture ───────────────────────────────────────────────────────────────
846
847/// Extract a canonical doc comment from `content` and update `file:{path}` record.
848///
849/// No-ops when: no record exists, record source is not StaticAnalysis, or no
850/// doc comment found in content.
851pub 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    // Update only records nobody has manually curated: a Layer 0 stub, or a
864    // prior doc-capture pass. Never overwrite DeveloperManual, ClaudeEnrich,
865    // or Import — those reflect a human or an enrichment pass, not this
866    // heuristic scan. Excluding SessionHook here would make this function's
867    // own prior write permanently block every later re-capture of the file.
868    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
897// ── Doc comment extraction ────────────────────────────────────────────────────
898
899pub 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
971// ── M-12-D: Gotcha auto-promotion ────────────────────────────────────────────
972
973pub 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        // NOTE: confirmation_count includes auto-promotions. Downstream consumers
992        // should not assume this counter reflects only human confirmations.
993        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
1004// ── M-13-C: Stale review collection ──────────────────────────────────────────
1005
1006pub 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    // Merge: new entries take priority, dedup by key
1053    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    // Sort descending by staleness, truncate
1067    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        // Exclude non-Active lifecycle
1097        if !matches!(record.lifecycle, RecordLifecycle::Active) {
1098            continue;
1099        }
1100
1101        // Exclude Liability and Tombstone tiers
1102        if matches!(
1103            record.staleness.tier,
1104            StalenessTier::Liability | StalenessTier::Tombstone
1105        ) {
1106            continue;
1107        }
1108
1109        // Filter to [STALE_REVIEW_MIN, STALE_REVIEW_MAX) range
1110        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    // ── receipt provenance ───────────────────────────────────────────────────
1180
1181    /// The source reaches the stored payload, not just the argument list.
1182    ///
1183    /// Reads back through the same serialization the store uses, so a receipt
1184    /// that records provenance in memory but drops it on write fails here.
1185    #[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    /// An unset source stays unset. Guards the deliberate choice at the mint
1213    /// sites that cannot name their provenance — a default would invent one.
1214    #[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    /// Receipts written before the field existed must still load. They are the
1226    /// majority of any store upgrading into this change.
1227    #[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    // ── session_harvest ──────────────────────────────────────────────────────
1315
1316    /// A git worktree with one committed file. Returns the dir and the HEAD SHA.
1317    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    /// The wired path actually runs git staleness. `last_record_sha` is written
1367    /// only by `StalenessAnalyzer::git_factor`, so finding HEAD there after a
1368    /// harvest proves `analyze_all` ran — this was inert since the daemon split.
1369    #[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    /// Staleness runs on the SessionEnd path. When git is unusable the harvest
1394    /// must still archive the session, clear receipts, and stamp `stage:current`.
1395    #[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        // No git repo anywhere above a tempdir — the analyzer can do nothing.
1407        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        // And nothing was tombstoned off an unproven root.
1425        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        // SUBAGENT_SUMMARY_MAX chars plus the ellipsis marker.
1499        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        // Harvest with no git repo — still runs the archive + cleanup path.
1519        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    /// The staleness sweep parks a resume cursor when its budget runs out. That
1537    /// state is on the Eventual path and reachable by the SessionEnd hook, so a
1538    /// junk value must cost a fair sweep and nothing else — not the archive,
1539    /// not the receipts, not the harvest's return value.
1540    #[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        // Literals on purpose: the cursor key and its payload field are private
1550        // to `health::staleness`, which owns the only writes to them.
1551        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        // Actor-scoped receipt: actor Some("agentA").
1757        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        // Global receipt: actor None.
1772        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        // Write an actor-scoped receipt for agentA / file:x.
1788        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        // agentA's own receipt is found.
1793        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        // A DIFFERENT subagent (agentB) does NOT see agentA's receipt.
1801        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        // Write a GLOBAL receipt for file:y (main-thread path).
1809        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        // Main thread (actor=None) sees the global receipt unchanged.
1814        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        // A subagent does NOT ride the global (main-thread) receipt.
1822        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        // Verify receipts exist before clearing.
1840        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    // ── doc_capture ───────────────────────────────────────────────────────────
1858
1859    /// Regression: before the fix, the update gate checked
1860    /// `record.source != RecordSource::StaticAnalysis`, so the very first
1861    /// capture's `SessionHook` stamp made every later capture on the same
1862    /// file a permanent no-op — 19 records in the live store were locked
1863    /// this way. A second capture with different content must still refresh
1864    /// the purpose while the record stays `SessionHook`-sourced.
1865    #[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    /// The gate must still protect developer- and enrichment-authored records
1925    /// from being clobbered by the heuristic doc-comment scan.
1926    #[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    // ── worktree_scope_tag ───────────────────────────────────────────────────
1973
1974    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    /// A git repo with one commit, ready for `git worktree add`.
1984    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    /// The common layout: `git worktree add ../feature`. The worktree's own
1996    /// `.git` is a pointer *file*, not a directory, and it has no real `.git`
1997    /// directory anywhere in its own ancestry (its sibling relationship to
1998    /// the main checkout means walking up never reaches one either).
1999    #[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    /// The exact bug case: a worktree nested INSIDE the main repo's directory
2023    /// tree (e.g. `.worktrees/<branch>`). A lexical `.git`-file check (as a
2024    /// naive walk-up would do) walks past the worktree's own `.git` pointer
2025    /// file and lands on the main repo's real `.git` directory, wrongly
2026    /// treating the nested worktree as if it were the main checkout.
2027    /// `git2::Repository::discover` must not make that mistake.
2028    #[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}