Skip to main content

kimetsu_brain/
digest.rs

1//! Flagship 1 / Pass B / Story 1.1 + 1.2: repo digest builder.
2//!
3//! Builds a compact ~400-token digest of the current repo state:
4//!   - top-usefulness memories (conventions/facts that matter most)
5//!   - repo manifest summary (Cargo.toml, package.json, …)
6//! Task focus is delivered separately through identity-scoped resume.
7//!
8//! The digest is cached in `.kimetsu/digest.md`, keyed by a non-cryptographic
9//! CONTENT HASH of current inputs. Warm delivery validates the inputs
10//! synchronously so corrected or expired claims cannot survive in cached text.
11//!
12//! ## Cheap-model vs rule-based
13//!
14//! When `config.cheap_model()` returns `Some(cm)` the digest is distilled
15//! by an LLM call (not yet wired — requires async HTTP client that is
16//! already present in the distiller).  When `None`, a rule-based assembler
17//! concatenates the raw inputs directly.  The rule-based path is the only
18//! path exercised in tests and in the current implementation (the
19//! expensive LLM path is guarded and degrades gracefully).
20//!
21//! ## ROI attribution
22//!
23//! After the SessionStart hook emits context, it writes `digest_served` /
24//! `resume_served` attribution events to the brain via
25//! [`record_warmstart_served`].
26
27use std::collections::hash_map::DefaultHasher;
28use std::hash::{Hash, Hasher};
29use std::path::Path;
30
31use kimetsu_core::KimetsuResult;
32use rusqlite::Connection;
33use serde::{Deserialize, Serialize};
34
35use crate::project::{load_project, load_project_readonly};
36
37// ── Target size ──────────────────────────────────────────────────────────────
38
39/// Approx character budget for the assembled digest (≈400 tokens × 4 chars).
40const DIGEST_CHAR_BUDGET: usize = 1_600;
41/// Number of top-useful memories to include in the digest.
42const TOP_MEMORY_COUNT: usize = 5;
43/// Max chars per memory text included in digest.
44const MEMORY_SNIPPET_CHARS: usize = 180;
45
46// ── Cache metadata ────────────────────────────────────────────────────────────
47
48#[derive(Debug, Clone, Serialize, Deserialize)]
49pub struct DigestMeta {
50    /// Non-cryptographic content hash of the inputs (DefaultHasher).
51    pub input_hash: u64,
52    /// ISO-8601 timestamp when this digest was built.
53    pub built_at: String,
54}
55
56// ── Public surface ────────────────────────────────────────────────────────────
57
58/// Build (or load from cache) a compact repo digest for `workspace`.
59///
60/// Returns `None` when:
61/// - the brain is not initialized at `workspace`
62/// - the workspace has no useful content yet (no memories, no manifests)
63///
64/// The returned string is already budget-capped and ready for injection.
65///
66/// `force_rebuild` bypasses the cache.
67pub fn build_or_load_digest(workspace: &Path, force_rebuild: bool) -> Option<String> {
68    build_or_load_digest_inner(workspace, force_rebuild).unwrap_or(None)
69}
70
71fn build_or_load_digest_inner(
72    workspace: &Path,
73    force_rebuild: bool,
74) -> KimetsuResult<Option<String>> {
75    let (paths, config, conn) = load_project_readonly(workspace)?;
76    let repo_root_str = paths.repo_root.to_string_lossy().to_string();
77
78    // 1. Assemble raw inputs.
79    let inputs = gather_inputs(&conn, &repo_root_str)?;
80    if inputs.is_empty() {
81        return Ok(None);
82    }
83
84    // 2. Compute content hash.
85    let hash = content_hash(&inputs);
86
87    // 3. Cache paths.
88    let cache_path = paths.kimetsu_dir.join("digest.md");
89    let meta_path = paths.kimetsu_dir.join("digest-meta.json");
90
91    // The rule-based assembly is cheap and binds delivery to this exact input
92    // snapshot. Separate diagnostic cache publishers can mix text/metadata
93    // generations, so an input-hash match alone cannot authorize cached text.
94    let digest_text = assemble_rule_based(&inputs, &config)?;
95    if digest_text.trim().is_empty() {
96        return Ok(None);
97    }
98
99    // 4. Reuse the disk cache only as a reason to skip an unchanged write.
100    if !force_rebuild {
101        if let Some(cached) = try_load_cache(&cache_path, &meta_path, hash) {
102            if cached == digest_text {
103                return Ok(Some(digest_text));
104            }
105        }
106    }
107
108    // 6. Write cache atomically.
109    let meta = DigestMeta {
110        input_hash: hash,
111        built_at: now_utc_rfc3339(),
112    };
113    atomic_write_text(&cache_path, &digest_text);
114    atomic_write_json_meta(&meta_path, &meta);
115
116    Ok(Some(digest_text))
117}
118
119/// Read `.kimetsu/digest.md` verbatim, without checking whether it is
120/// still current.
121///
122/// This diagnostic raw read may return stale text. Model-facing warm delivery
123/// uses [`build_or_load_digest`] to validate current inputs before cache reuse.
124/// Returns `None` when the brain is not initialized or no cache exists.
125pub fn load_cached_digest(workspace: &Path) -> Option<String> {
126    let (paths, _config, _conn) = load_project_readonly(workspace).ok()?;
127    let text = std::fs::read_to_string(paths.kimetsu_dir.join("digest.md")).ok()?;
128    if text.trim().is_empty() {
129        None
130    } else {
131        Some(text)
132    }
133}
134
135// ── Staleness check (1.2) ─────────────────────────────────────────────────────
136
137/// Returns `true` when the cached digest is stale and should be rebuilt.
138///
139/// Reads the metadata and current bounded digest inputs to compare their hash.
140///
141/// Diagnostic helper; warm delivery validates through build_or_load_digest.
142pub fn is_stale(workspace: &Path) -> bool {
143    is_stale_inner(workspace).unwrap_or(false)
144}
145
146fn is_stale_inner(workspace: &Path) -> KimetsuResult<bool> {
147    let (paths, _config, conn) = load_project_readonly(workspace)?;
148    let repo_root_str = paths.repo_root.to_string_lossy().to_string();
149
150    let meta_path = paths.kimetsu_dir.join("digest-meta.json");
151    let cache_path = paths.kimetsu_dir.join("digest.md");
152
153    if !cache_path.exists() || !meta_path.exists() {
154        return Ok(true);
155    }
156
157    let meta = load_meta(&meta_path)?;
158    let inputs = gather_inputs(&conn, &repo_root_str)?;
159    let current_hash = content_hash(&inputs);
160
161    Ok(meta.input_hash != current_hash)
162}
163
164// ── Warm start ────────────────────────────────────────────────────────────────
165
166/// Assemble the warm-start block: repo digest, standing preferences, and
167/// episodic resume.
168///
169/// This is what every host sees first — the `SessionStart` hook on Claude
170/// Code, the first prompt of a session on Codex / Pi / OpenClaw, and the first
171/// `kimetsu_brain_context` call on Cursor, which has neither hooks nor a
172/// session-start surface.
173///
174/// Returns `None` when `[broker] warm_start` is off, or when there is no
175/// digest, no preferences and no live episode to report.
176///
177/// Current inputs are checked before cached text is used. Rule-based rebuilds
178/// run synchronously when claims change or temporal validity crosses a boundary.
179///
180/// Records ROI attribution as a side effect, so call it only when the block is
181/// actually going to be emitted.
182pub fn warm_start_block(workspace: &Path) -> Option<String> {
183    warm_start_block_scoped(workspace, "")
184}
185pub fn warm_start_block_scoped(workspace: &Path, identity: &str) -> Option<String> {
186    let block = prepare_warm_start_block_scoped(workspace, identity)?;
187    record_warmstart_served(workspace, block.digest_chars, block.resume_chars);
188    Some(block.context)
189}
190
191/// Prepared text carries no delivery attribution until the caller emits it.
192pub struct PreparedWarmStart {
193    pub context: String,
194    pub digest_chars: usize,
195    pub resume_chars: usize,
196}
197
198pub fn prepare_warm_start_block_scoped(
199    workspace: &Path,
200    identity: &str,
201) -> Option<PreparedWarmStart> {
202    // Gate: load warm_start from config (best-effort; default ON).
203    let warm_start_enabled = kimetsu_core::paths::ProjectPaths::discover(workspace)
204        .ok()
205        .and_then(|paths| crate::project::load_config(&paths).ok())
206        .map(|cfg| cfg.broker.warm_start)
207        .unwrap_or(true);
208    if !warm_start_enabled {
209        return None;
210    }
211
212    // Validate current claim text, retirement and temporal applicability before
213    // using a cached overview. A stale-while-revalidate policy reintroduces
214    // facts the retrieval path deliberately rejected.
215    let digest = build_or_load_digest(workspace, false);
216    let resume = crate::episode::render_resume_context_scoped(workspace, identity);
217
218    // v2.6: the user's standing preferences, delivered rather than retrieved.
219    //
220    // Preference following is the second-weakest measured ability, and the
221    // diagnosis is that "a preference is a small aside semantically far from
222    // the question" — which rules out re-ranking, because the candidate never
223    // enters the pool. A standing preference belongs in context before the
224    // question is asked. See `crate::user_profile`.
225    let profile = user_profile_block(workspace);
226
227    // v2.6: what the skills loop is waiting on. Detection has run on a schedule
228    // since the maintenance daemon landed, but its result went into a log file
229    // nobody opens — so a memory could earn skill status and never become one.
230    // See `crate::skill_synthesis::graduation_notice`.
231    let skills = skills_block(workspace);
232
233    if digest.is_none() && resume.is_none() && profile.is_none() && skills.is_none() {
234        return None;
235    }
236
237    let mut parts: Vec<String> = Vec::new();
238    if let Some(d) = &digest {
239        parts.push(format!("## Repo context\n{d}"));
240    }
241    if let Some(p) = &profile {
242        parts.push(format!("## How you like to work\n{p}"));
243    }
244    if let Some(r) = &resume {
245        parts.push(format!("## Your prior session\n{r}"));
246    }
247    // Last: it is a nudge about Kimetsu itself, not context about the repo, so
248    // it must not sit between the agent and the work.
249    if let Some(s) = &skills {
250        parts.push(format!("## Skills ready to graduate\n{s}"));
251    }
252
253    Some(PreparedWarmStart {
254        context: parts.join("\n\n"),
255        digest_chars: digest.as_ref().map(|d| d.len()).unwrap_or(0),
256        resume_chars: resume.as_ref().map(|r| r.len()).unwrap_or(0),
257    })
258}
259
260/// Assemble the skills-loop nudge for the warm start.
261///
262/// Best-effort, like every other block here: an unreadable brain means no
263/// nudge, never a failed warm start.
264fn skills_block(workspace: &Path) -> Option<String> {
265    let (_paths, _config, conn) = load_project_readonly(workspace).ok()?;
266    crate::skill_synthesis::graduation_notice(&conn)
267}
268
269/// Assemble the standing-preferences block for the warm start.
270///
271/// Best-effort: an unreadable brain means no preferences block, never a failed
272/// warm start.
273fn user_profile_block(workspace: &Path) -> Option<String> {
274    let (_paths, config, conn) = load_project_readonly(workspace).ok()?;
275    // The cross-project user brain is opened separately; when it is disabled or
276    // unreachable the project's own preferences stand on their own.
277    let user_conn =
278        crate::user_brain::open_user_brain_readonly_for_config(config.kimetsu.use_user_brain)
279            .ok()
280            .flatten();
281    let profile = crate::user_profile::build_profile(&conn, user_conn.as_ref()).ok()?;
282    crate::user_profile::render_profile(&profile)
283}
284
285// ── ROI attribution ───────────────────────────────────────────────────────────
286
287/// Record ROI attribution events for the warm-start injection.
288///
289/// `digest_chars` is the length of the emitted digest (0 = not emitted).
290/// `resume_chars` is the length of the emitted resume (0 = not emitted).
291///
292/// Best-effort: errors are ignored (ROI must never block SessionStart).
293pub fn record_warmstart_served(workspace: &Path, digest_chars: usize, resume_chars: usize) {
294    let _ = record_warmstart_served_inner(workspace, digest_chars, resume_chars);
295}
296
297fn record_warmstart_served_inner(
298    workspace: &Path,
299    digest_chars: usize,
300    resume_chars: usize,
301) -> KimetsuResult<()> {
302    if digest_chars == 0 && resume_chars == 0 {
303        return Ok(());
304    }
305    let (_paths, _config, conn) = load_project(workspace)?;
306    let ts = now_utc_rfc3339();
307
308    if digest_chars > 0 {
309        let approx_tokens = digest_chars / 4;
310        let event = kimetsu_core::event::Event::new(
311            kimetsu_core::ids::RunId::new(),
312            "digest_served",
313            serde_json::json!({
314                "digest_chars": digest_chars,
315                "approx_tokens": approx_tokens,
316                "ts": ts,
317            }),
318        );
319        let _ = crate::projector::insert_event(&conn, &event);
320    }
321
322    if resume_chars > 0 {
323        let approx_tokens = resume_chars / 4;
324        let event = kimetsu_core::event::Event::new(
325            kimetsu_core::ids::RunId::new(),
326            "resume_served",
327            serde_json::json!({
328                "resume_chars": resume_chars,
329                "approx_tokens": approx_tokens,
330                "ts": ts,
331            }),
332        );
333        let _ = crate::projector::insert_event(&conn, &event);
334    }
335
336    Ok(())
337}
338
339// ── Input assembly ────────────────────────────────────────────────────────────
340
341/// Raw ingredients for the digest.
342#[derive(Debug, Default)]
343struct DigestInputs {
344    /// Top-useful memory snippets: `(kind, text_snippet)`.
345    top_memories: Vec<(String, String)>,
346    /// Manifest summaries: `(manifest_kind, path)` e.g. ("cargo", "Cargo.toml").
347    manifests: Vec<(String, String)>,
348}
349
350impl DigestInputs {
351    fn is_empty(&self) -> bool {
352        self.top_memories.is_empty() && self.manifests.is_empty()
353    }
354}
355
356fn gather_inputs(conn: &Connection, repo_root: &str) -> KimetsuResult<DigestInputs> {
357    let mut inputs = DigestInputs::default();
358
359    // Top-useful memories (conventions/facts, no superseded/invalidated).
360    // Include memories with use_count = 0 (fresh adds) ordered by recency
361    // so new brains produce useful digests without requiring prior runs.
362    // use_count > 0 memories are ranked by usefulness ratio; use_count = 0
363    // rows sort last (usefulness_score default 0).
364    //
365    // v2.6: preferences are excluded. They now have their own warm-start
366    // section (`crate::user_profile`), which sits directly beside this one, so
367    // including them here would print the same lines twice in the same block —
368    // and the digest's slots are better spent on facts the preferences section
369    // will never carry.
370    {
371        let mut stmt = conn.prepare(
372            "SELECT kind, text
373             FROM memories
374             WHERE invalidated_at IS NULL
375               AND superseded_by IS NULL
376               AND (valid_from IS NULL OR julianday(valid_from) <= julianday('now'))
377               AND (valid_to IS NULL OR julianday(valid_to) > julianday('now'))
378               AND kind != 'preference'
379             ORDER BY
380               CASE WHEN use_count > 0
381                    THEN (usefulness_score / CAST(use_count AS REAL))
382                    ELSE 0.0
383               END DESC,
384               use_count DESC,
385               created_at DESC
386             LIMIT ?1",
387        )?;
388        let rows = stmt.query_map([TOP_MEMORY_COUNT as i64], |row| {
389            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
390        })?;
391        for (kind, text) in rows.flatten() {
392            let snippet: String = text.chars().take(MEMORY_SNIPPET_CHARS).collect();
393            inputs.top_memories.push((kind, snippet));
394        }
395    }
396
397    // Repo manifests (Cargo.toml, package.json, pyproject.toml, …)
398    {
399        let mut stmt = conn.prepare(
400            "SELECT manifest_kind, manifest_path
401             FROM repo_manifests
402             WHERE repo_root = ?1
403             LIMIT 10",
404        )?;
405        let rows = stmt.query_map([repo_root], |row| {
406            Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
407        })?;
408        for pair in rows.flatten() {
409            inputs.manifests.push(pair);
410        }
411    }
412
413    // Task focus belongs exclusively to the identity-scoped resume block.
414
415    Ok(inputs)
416}
417
418// ── Content hash ──────────────────────────────────────────────────────────────
419
420fn content_hash(inputs: &DigestInputs) -> u64 {
421    let mut h = DefaultHasher::new();
422    for (kind, text) in &inputs.top_memories {
423        kind.hash(&mut h);
424        text.hash(&mut h);
425    }
426    for (mk, mp) in &inputs.manifests {
427        mk.hash(&mut h);
428        mp.hash(&mut h);
429    }
430    h.finish()
431}
432
433// ── Rule-based assembler ──────────────────────────────────────────────────────
434
435fn assemble_rule_based(
436    inputs: &DigestInputs,
437    _config: &kimetsu_core::config::ProjectConfig,
438) -> KimetsuResult<String> {
439    let mut parts: Vec<String> = Vec::new();
440
441    // Manifests → project type hint.
442    if !inputs.manifests.is_empty() {
443        let manifest_list: Vec<String> = inputs
444            .manifests
445            .iter()
446            .map(|(kind, path)| format!("{kind}: {path}"))
447            .collect();
448        parts.push(format!("Project manifests: {}", manifest_list.join(", ")));
449    }
450
451    // Top memories.
452    if !inputs.top_memories.is_empty() {
453        parts.push("Key conventions and facts:".to_string());
454        for (kind, text) in &inputs.top_memories {
455            parts.push(format!("[{kind}] {text}"));
456        }
457    }
458
459    let digest = parts.join("\n");
460
461    // Budget-cap: truncate to char limit with ellipsis.
462    if digest.len() > DIGEST_CHAR_BUDGET {
463        let mut s: String = digest.chars().take(DIGEST_CHAR_BUDGET - 3).collect();
464        s.push_str("...");
465        Ok(s)
466    } else {
467        Ok(digest)
468    }
469}
470
471// ── Cache helpers ─────────────────────────────────────────────────────────────
472
473fn try_load_cache(cache_path: &Path, meta_path: &Path, current_hash: u64) -> Option<String> {
474    if !cache_path.exists() || !meta_path.exists() {
475        return None;
476    }
477    let meta = load_meta(meta_path).ok()?;
478    if meta.input_hash != current_hash {
479        return None;
480    }
481    std::fs::read_to_string(cache_path).ok()
482}
483
484fn load_meta(meta_path: &Path) -> KimetsuResult<DigestMeta> {
485    let text = std::fs::read_to_string(meta_path)?;
486    Ok(serde_json::from_str(&text)?)
487}
488
489/// Atomic text write: temp + rename.
490fn atomic_write_text(path: &Path, content: &str) {
491    let Some(parent) = path.parent() else {
492        return;
493    };
494    let _ = std::fs::create_dir_all(parent);
495    let tmp = path.with_extension("md.tmp");
496    if std::fs::write(&tmp, content).is_ok() {
497        let _ = std::fs::rename(&tmp, path);
498    }
499}
500
501/// Atomic JSON meta write: temp + rename.
502fn atomic_write_json_meta(path: &Path, meta: &DigestMeta) {
503    let Some(parent) = path.parent() else {
504        return;
505    };
506    let _ = std::fs::create_dir_all(parent);
507    let Ok(text) = serde_json::to_string(meta) else {
508        return;
509    };
510    let tmp = path.with_extension("json.tmp");
511    if std::fs::write(&tmp, &text).is_ok() {
512        let _ = std::fs::rename(&tmp, path);
513    }
514}
515
516fn now_utc_rfc3339() -> String {
517    time::OffsetDateTime::now_utc()
518        .format(&time::format_description::well_known::Rfc3339)
519        .unwrap_or_default()
520}
521
522// ── Tests ─────────────────────────────────────────────────────────────────────
523
524#[cfg(test)]
525mod tests {
526    use kimetsu_core::paths::git_init_boundary;
527
528    use super::*;
529    use crate::{project, user_brain};
530
531    #[test]
532    fn hardening_warm_profile_honors_user_brain_opt_out() {
533        user_brain::with_user_brain_disabled(|| {
534            let dir = tmp_workspace("hardening-warm-profile-off");
535            git_init_boundary(&dir);
536            project::init_project(&dir, false).unwrap();
537            let global_dir = dir.join("isolated-global");
538            std::fs::create_dir_all(&global_dir).unwrap();
539            // The shared test-env lock is held by with_user_brain_disabled.
540            unsafe {
541                std::env::set_var("KIMETSU_USER_BRAIN_DIR", &global_dir);
542            }
543            let global =
544                Connection::open(kimetsu_core::paths::user_brain_db_path().unwrap()).unwrap();
545            crate::schema::initialize(&global).unwrap();
546            global.execute("INSERT INTO memories(memory_id,scope,kind,text,normalized_text,confidence,provenance_snapshot_json,created_at) VALUES('global','global_user','preference','PRIVATE_GLOBAL','private_global',1.0,'{}','2026-01-01T00:00:00Z')", []).unwrap();
547            let env_disabled = user_profile_block(&dir);
548            let (paths, mut config, conn) = load_project_readonly(&dir).unwrap();
549            config.kimetsu.use_user_brain = false;
550            std::fs::write(paths.project_toml, config.to_toml().unwrap()).unwrap();
551            unsafe {
552                std::env::remove_var("KIMETSU_USER_BRAIN");
553            }
554            let config_disabled = user_profile_block(&dir);
555            unsafe {
556                std::env::set_var("KIMETSU_USER_BRAIN", "0");
557                std::env::remove_var("KIMETSU_USER_BRAIN_DIR");
558            }
559            drop(conn);
560            drop(global);
561            std::fs::remove_dir_all(dir).unwrap();
562            assert!(
563                env_disabled.is_none(),
564                "environment opt-out leaked global profile"
565            );
566            assert!(
567                config_disabled.is_none(),
568                "project opt-out leaked global profile"
569            );
570        });
571    }
572
573    #[test]
574    fn hardening_warm_digest_revalidates_corrected_and_retired_claims() {
575        user_brain::with_user_brain_disabled(|| {
576            let dir = tmp_workspace("hardening-warm-current");
577            git_init_boundary(&dir);
578            project::init_project(&dir, false).unwrap();
579            let id = project::add_memory(
580                &dir,
581                kimetsu_core::memory::MemoryScope::Project,
582                kimetsu_core::memory::MemoryKind::Fact,
583                "ORIGINAL port is 4001",
584            )
585            .unwrap();
586            assert!(
587                build_or_load_digest(&dir, true)
588                    .unwrap()
589                    .contains("ORIGINAL")
590            );
591            project::edit_memory(&dir, &id, Some("CORRECTED port is 4002"), None).unwrap();
592            let block = warm_start_block_scoped(&dir, "lane-a").unwrap();
593            assert!(
594                !block.contains("ORIGINAL"),
595                "stale cache must never reintroduce corrected text"
596            );
597            assert!(block.contains("CORRECTED"));
598            // Separate cache-file publishers can leave old text with current
599            // input metadata. Delivery must bind to the gathered inputs anyway.
600            let (paths, _, conn) = load_project_readonly(&dir).unwrap();
601            std::fs::write(paths.kimetsu_dir.join("digest.md"), "ORIGINAL port is 4001").unwrap();
602            let mixed = warm_start_block_scoped(&dir, "lane-a").unwrap();
603            assert!(
604                mixed.contains("CORRECTED") && !mixed.contains("ORIGINAL"),
605                "mixed cache generations leaked: {mixed}"
606            );
607            drop(conn);
608            project::invalidate_memory(&dir, &id, Some("wrong claim")).unwrap();
609            assert!(
610                !warm_start_block_scoped(&dir, "lane-a")
611                    .unwrap_or_default()
612                    .contains("CORRECTED")
613            );
614            std::fs::remove_dir_all(dir).unwrap();
615        });
616    }
617
618    #[test]
619    fn hardening_warm_digest_excludes_invalid_time_and_other_task_focus() {
620        user_brain::with_user_brain_disabled(|| {
621            let dir = tmp_workspace("hardening-warm-validity");
622            git_init_boundary(&dir);
623            project::init_project(&dir, false).unwrap();
624            for (text, from, to) in [
625                ("CURRENT endpoint", None, None),
626                ("FUTURE endpoint", Some("2099-01-01T00:00:00Z"), None),
627                ("EXPIRED endpoint", None, Some("2020-01-01T00:00:00Z")),
628            ] {
629                project::add_memory_with_validity(
630                    &dir,
631                    kimetsu_core::memory::MemoryScope::Project,
632                    kimetsu_core::memory::MemoryKind::Fact,
633                    text,
634                    from,
635                    to,
636                )
637                .unwrap();
638            }
639            for lane in ["ALPHA", "BETA"] {
640                crate::episode::capture_episode(
641                    &dir,
642                    crate::episode::EpisodePayload {
643                        identity: lane.into(),
644                        task: format!("{lane} task title"),
645                        summary: format!("{lane} task state"),
646                        ..Default::default()
647                    },
648                )
649                .unwrap();
650            }
651            let block = warm_start_block_scoped(&dir, "ALPHA").unwrap();
652            assert!(block.contains("CURRENT") && block.contains("ALPHA"));
653            assert!(
654                !block.contains("FUTURE") && !block.contains("EXPIRED") && !block.contains("BETA"),
655                "{block}"
656            );
657            std::fs::remove_dir_all(dir).unwrap();
658        });
659    }
660
661    fn tmp_workspace(name: &str) -> std::path::PathBuf {
662        let ts = std::time::SystemTime::now()
663            .duration_since(std::time::UNIX_EPOCH)
664            .map(|d| d.as_nanos())
665            .unwrap_or(0);
666        let dir = std::env::temp_dir().join(format!("kimetsu-digest-{name}-{ts}"));
667        std::fs::create_dir_all(&dir).expect("create tmp");
668        dir
669    }
670
671    // D1: empty brain returns None (no content to digest).
672    #[test]
673    fn empty_brain_returns_none() {
674        let dir = tmp_workspace("empty");
675        git_init_boundary(&dir);
676        user_brain::with_user_brain_disabled(|| {
677            project::init_project(&dir, true).expect("init");
678            let result = build_or_load_digest(&dir, false);
679            assert!(result.is_none(), "empty brain must return None digest");
680        });
681        std::fs::remove_dir_all(dir).ok();
682    }
683
684    // D2: digest with memories is non-empty and ≤ budget.
685    #[test]
686    fn digest_with_memories_is_bounded() {
687        let dir = tmp_workspace("bounded");
688        git_init_boundary(&dir);
689        user_brain::with_user_brain_disabled(|| {
690            project::init_project(&dir, true).expect("init");
691            // Seed a memory so there's content to digest.
692            // The digest includes memories even with use_count=0 (fresh adds).
693            project::add_memory(
694                &dir,
695                kimetsu_core::memory::MemoryScope::Project,
696                kimetsu_core::memory::MemoryKind::Convention,
697                "Always use git_init_boundary before init_project in tests",
698            )
699            .expect("add_memory");
700
701            let digest = build_or_load_digest(&dir, true).expect("digest must be Some");
702            assert!(!digest.is_empty(), "digest must be non-empty");
703            assert!(
704                digest.len() <= DIGEST_CHAR_BUDGET + 3,
705                "digest must respect char budget: {} chars",
706                digest.len()
707            );
708        });
709        std::fs::remove_dir_all(dir).ok();
710    }
711
712    // D3: cache is reused on second call (no force_rebuild).
713    #[test]
714    fn cache_is_reused_on_second_call() {
715        let dir = tmp_workspace("cache");
716        git_init_boundary(&dir);
717        user_brain::with_user_brain_disabled(|| {
718            project::init_project(&dir, true).expect("init");
719            project::add_memory(
720                &dir,
721                kimetsu_core::memory::MemoryScope::Project,
722                kimetsu_core::memory::MemoryKind::Fact,
723                "Rust edition 2024 is the target edition for this workspace",
724            )
725            .expect("add_memory");
726            let d1 = build_or_load_digest(&dir, true).expect("first build");
727            let d2 = build_or_load_digest(&dir, false).expect("cached load");
728            assert_eq!(d1, d2, "cached digest must match first build");
729        });
730        std::fs::remove_dir_all(dir).ok();
731    }
732
733    // D4: force_rebuild bypasses cache.
734    #[test]
735    fn force_rebuild_bypasses_cache() {
736        let dir = tmp_workspace("force");
737        git_init_boundary(&dir);
738        user_brain::with_user_brain_disabled(|| {
739            project::init_project(&dir, true).expect("init");
740            project::add_memory(
741                &dir,
742                kimetsu_core::memory::MemoryScope::Project,
743                kimetsu_core::memory::MemoryKind::Convention,
744                "Force rebuild test convention",
745            )
746            .expect("add_memory");
747            let d1 = build_or_load_digest(&dir, true).expect("first build");
748            let d2 = build_or_load_digest(&dir, true).expect("forced rebuild");
749            // Content should match because inputs are the same.
750            assert_eq!(
751                d1, d2,
752                "forced rebuild must produce same content when inputs unchanged"
753            );
754        });
755        std::fs::remove_dir_all(dir).ok();
756    }
757
758    // D5: is_stale returns true when no cache exists.
759    #[test]
760    fn is_stale_true_when_no_cache() {
761        let dir = tmp_workspace("stale");
762        git_init_boundary(&dir);
763        user_brain::with_user_brain_disabled(|| {
764            project::init_project(&dir, true).expect("init");
765            assert!(is_stale(&dir), "must be stale when cache does not exist");
766        });
767        std::fs::remove_dir_all(dir).ok();
768    }
769
770    // D6: is_stale returns false after a successful build.
771    #[test]
772    fn is_stale_false_after_build() {
773        let dir = tmp_workspace("fresh");
774        git_init_boundary(&dir);
775        user_brain::with_user_brain_disabled(|| {
776            project::init_project(&dir, true).expect("init");
777            project::add_memory(
778                &dir,
779                kimetsu_core::memory::MemoryScope::Project,
780                kimetsu_core::memory::MemoryKind::Fact,
781                "After-build staleness check fact",
782            )
783            .expect("add_memory");
784            let _ = build_or_load_digest(&dir, true);
785            assert!(
786                !is_stale(&dir),
787                "must NOT be stale immediately after a fresh build"
788            );
789        });
790        std::fs::remove_dir_all(dir).ok();
791    }
792
793    // D6b: load_cached_digest returns the cached text without rebuilding, and
794    // keeps returning it once the corpus has moved on. This is what lets the
795    // warm start serve instantly and rebuild off the hot path.
796    #[test]
797    fn load_cached_digest_serves_stale_text() {
798        let dir = tmp_workspace("cached-stale");
799        git_init_boundary(&dir);
800        user_brain::with_user_brain_disabled(|| {
801            project::init_project(&dir, true).expect("init");
802            assert!(
803                load_cached_digest(&dir).is_none(),
804                "nothing cached yet on a cold brain"
805            );
806
807            project::add_memory(
808                &dir,
809                kimetsu_core::memory::MemoryScope::Project,
810                kimetsu_core::memory::MemoryKind::Fact,
811                "Cached digest fact",
812            )
813            .expect("add_memory");
814            let built = build_or_load_digest(&dir, true).expect("first build");
815            assert_eq!(load_cached_digest(&dir).as_deref(), Some(built.as_str()));
816
817            // Move the corpus: the cache is now stale, but still servable.
818            project::add_memory(
819                &dir,
820                kimetsu_core::memory::MemoryScope::Project,
821                kimetsu_core::memory::MemoryKind::Convention,
822                "A second memory that invalidates the digest hash",
823            )
824            .expect("add_memory");
825            assert!(is_stale(&dir), "corpus moved — cache must read as stale");
826            assert_eq!(
827                load_cached_digest(&dir).as_deref(),
828                Some(built.as_str()),
829                "stale cache is still served verbatim"
830            );
831        });
832        std::fs::remove_dir_all(dir).ok();
833    }
834
835    // D6c: warm_start_block honours the [broker] warm_start gate, and produces
836    // the digest section when there is content.
837    #[test]
838    fn warm_start_block_respects_gate_and_renders_digest() {
839        let dir = tmp_workspace("warm-block");
840        git_init_boundary(&dir);
841        user_brain::with_user_brain_disabled(|| {
842            project::init_project(&dir, true).expect("init");
843            project::add_memory(
844                &dir,
845                kimetsu_core::memory::MemoryScope::Project,
846                kimetsu_core::memory::MemoryKind::Convention,
847                "Warm start block convention",
848            )
849            .expect("add_memory");
850
851            let block = warm_start_block(&dir).expect("warm start must have content");
852            assert!(
853                block.contains("## Repo context"),
854                "warm start must carry the repo digest: {block}"
855            );
856
857            // Turn the gate off; the block must disappear entirely.
858            let paths = kimetsu_core::paths::ProjectPaths::discover(&dir).expect("paths");
859            let mut config = crate::project::load_config(&paths).expect("config");
860            config.broker.warm_start = false;
861            std::fs::write(&paths.project_toml, config.to_toml().expect("to_toml"))
862                .expect("write project.toml");
863
864            assert!(
865                warm_start_block(&dir).is_none(),
866                "[broker] warm_start = false must silence the warm start"
867            );
868        });
869        std::fs::remove_dir_all(dir).ok();
870    }
871
872    // D7: digest size is ≤ ~400 tokens (character proxy: 1600 chars).
873    // This is the measurement/gate required by Story 1.6.
874    #[test]
875    fn digest_size_within_400_token_budget() {
876        // Assemble a large set of inputs and verify the rule-based assembler
877        // respects the budget.
878        let inputs = DigestInputs {
879            top_memories: (0..10)
880                .map(|i| {
881                    (
882                        "convention".to_string(),
883                        "A".repeat(MEMORY_SNIPPET_CHARS) + &format!(" #{i}"),
884                    )
885                })
886                .collect(),
887            manifests: (0..5)
888                .map(|i| ("cargo".to_string(), format!("Cargo{i}.toml")))
889                .collect(),
890        };
891        let config = kimetsu_core::config::ProjectConfig::default_for_project("test");
892        let digest = assemble_rule_based(&inputs, &config).expect("assemble");
893        let char_count = digest.chars().count();
894        assert!(
895            char_count <= DIGEST_CHAR_BUDGET + 3,
896            "digest must fit in budget: got {char_count} chars (budget={DIGEST_CHAR_BUDGET})"
897        );
898        // Approximate token count: chars / 4.
899        let approx_tokens = char_count / 4;
900        assert!(
901            approx_tokens <= 420,
902            "approx token count {approx_tokens} must be ≤ 420"
903        );
904    }
905
906    // D8: record_warmstart_served is best-effort (no panic on uninitialized brain).
907    #[test]
908    fn record_warmstart_served_is_best_effort() {
909        let tmp = std::env::temp_dir().join("kimetsu-digest-roi-besteffort");
910        // No brain initialized — must not panic.
911        record_warmstart_served(&tmp, 500, 100);
912    }
913}