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