Skip to main content

kimetsu_brain/
project.rs

1use std::fs;
2use std::path::{Path, PathBuf};
3
4use kimetsu_core::config::ProjectConfig;
5use kimetsu_core::env_file::resolve_env_value;
6use kimetsu_core::event::Event;
7use kimetsu_core::ids::RunId;
8use kimetsu_core::memory::{MemoryKind, MemoryScope, normalize_memory_text};
9use kimetsu_core::paths::{ProjectPaths, default_project_id};
10use kimetsu_core::{KIMETSU_SCHEMA_VERSION, KimetsuResult};
11use rusqlite::{Connection, OpenFlags, OptionalExtension, params};
12use ulid::Ulid;
13
14use crate::benchmark;
15use crate::conflict;
16use crate::context::{self, ContextBundle, ContextRequest};
17use crate::embeddings;
18use crate::ingest::{self, RepoIngestSummary};
19use crate::lock::ProjectLock;
20use crate::projector;
21use crate::redact;
22use crate::schema;
23use crate::trace::{self, TraceWriter};
24use crate::user_brain;
25
26#[derive(Debug, Clone)]
27pub struct InitSummary {
28    pub project_id: String,
29    pub repo_root: PathBuf,
30    pub kimetsu_dir: PathBuf,
31    pub brain_db: PathBuf,
32    pub model: String,
33    pub api_key_env: String,
34    pub api_key_present: bool,
35    pub wrote_project_toml: bool,
36}
37
38#[derive(Debug, Clone)]
39pub struct RunSummary {
40    pub run_id: String,
41    pub task: String,
42    pub started_at: String,
43    pub terminal_kind: Option<String>,
44}
45
46#[derive(Debug, Clone)]
47pub struct MemoryRow {
48    pub memory_id: String,
49    pub scope: String,
50    pub kind: String,
51    pub text: String,
52    pub confidence: f32,
53    pub use_count: u32,
54    /// MP-4a: running net outcome score. +1 for each run.finished that
55    /// surfaced this memory; -1 for each run.failed (excluding Gate
56    /// failures). Use_count tracks all updates, useful as a small-sample
57    /// guard before letting the score bias retrieval.
58    pub usefulness_score: f32,
59}
60
61#[derive(Debug, Clone)]
62pub struct ProposalRow {
63    pub proposal_id: String,
64    pub run_id: String,
65    pub scope: String,
66    pub kind: String,
67    pub text: String,
68    pub rationale: String,
69    pub proposed_confidence: f32,
70    pub status: String,
71    pub decided_reason: Option<String>,
72}
73
74#[derive(Debug, Clone, Default)]
75pub struct ProposalFilter {
76    pub scope: Option<String>,
77    pub kind: Option<String>,
78    pub from_run: Option<String>,
79    pub min_confidence: Option<f32>,
80    pub status: Option<String>,
81    pub limit: u32,
82}
83
84#[derive(Debug, Clone, Default)]
85pub struct AcceptOverrides {
86    pub scope: Option<String>,
87    pub confidence: Option<f32>,
88}
89
90#[derive(Debug, Clone)]
91pub struct RecordedBenchmarkOutcome {
92    pub memory_id: String,
93    pub task_slug: Option<String>,
94    pub kind: MemoryKind,
95    pub text: String,
96    pub proposal_id: Option<String>,
97    pub proposal_text: Option<String>,
98}
99
100// v0.5.1: blame surface — per-run memory attribution. Both the CLI
101// (`kimetsu brain memory blame <run-id>`) and the MCP tool
102// (`kimetsu_brain_memory_blame`) consume `BlameReport`.
103
104#[derive(Debug, Clone, serde::Serialize)]
105pub struct BlameReport {
106    pub run_id: String,
107    /// Terminal outcome of the run: "success" (run.finished),
108    /// "failed" (run.failed), "aborted" (run.aborted), or "unknown"
109    /// (no terminal event found yet).
110    pub outcome: String,
111    /// Failure category when outcome is "failed" (e.g. "Gate",
112    /// "Implementation"). None otherwise.
113    pub failure_category: Option<String>,
114    /// Memories the model explicitly cited via `cite_memory`,
115    /// ordered by turn.
116    pub cited: Vec<CitedMemory>,
117    /// Memories that were retrieved into the run's context but
118    /// never cited. They got the weak ±0.1 signal instead of ±1.0.
119    pub silent_passengers: Vec<SilentMemory>,
120}
121
122#[derive(Debug, Clone, serde::Serialize)]
123pub struct CitedMemory {
124    pub memory_id: String,
125    pub turn: i64,
126    pub rationale: Option<String>,
127    pub cited_at: String,
128    /// Truncated memory text for human-readable output.
129    pub text_preview: String,
130    pub scope: String,
131    pub kind: String,
132}
133
134#[derive(Debug, Clone, serde::Serialize)]
135pub struct SilentMemory {
136    pub memory_id: String,
137    pub text_preview: String,
138    pub scope: String,
139    pub kind: String,
140}
141
142pub fn init_project(start: &Path, force: bool) -> KimetsuResult<InitSummary> {
143    let paths = ProjectPaths::discover(start)?;
144    fs::create_dir_all(&paths.runs_dir)?;
145
146    let project_id = default_project_id(&paths.repo_root);
147    let config = ProjectConfig::default_for_project(project_id);
148    let wrote_project_toml = if force || !paths.project_toml.exists() {
149        fs::write(&paths.project_toml, config.to_toml()?)?;
150        true
151    } else {
152        false
153    };
154
155    let config = load_config(&paths)?;
156    let conn = Connection::open(&paths.brain_db)?;
157    schema::initialize(&conn)?;
158
159    let api_key_present = resolve_env_value(&paths.repo_root, &config.model.api_key_env).is_some();
160
161    Ok(InitSummary {
162        project_id: config.kimetsu.project_id,
163        repo_root: paths.repo_root,
164        kimetsu_dir: paths.kimetsu_dir,
165        brain_db: paths.brain_db,
166        model: format!("{}/{}", config.model.provider, config.model.model),
167        api_key_env: config.model.api_key_env,
168        api_key_present,
169        wrote_project_toml,
170    })
171}
172
173pub fn load_project(start: &Path) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
174    let paths = ProjectPaths::discover(start)?;
175    let config = load_config(&paths)?;
176    if config.kimetsu.schema_version != KIMETSU_SCHEMA_VERSION {
177        return Err(format!(
178            "project.toml schema version {} does not match expected {}",
179            config.kimetsu.schema_version, KIMETSU_SCHEMA_VERSION
180        )
181        .into());
182    }
183
184    let conn = Connection::open(&paths.brain_db)?;
185    schema::initialize(&conn)?;
186    Ok((paths, config, conn))
187}
188
189pub fn load_project_readonly(
190    start: &Path,
191) -> KimetsuResult<(ProjectPaths, ProjectConfig, Connection)> {
192    let paths = ProjectPaths::discover(start)?;
193    let config = load_config(&paths)?;
194    if config.kimetsu.schema_version != KIMETSU_SCHEMA_VERSION {
195        return Err(format!(
196            "project.toml schema version {} does not match expected {}",
197            config.kimetsu.schema_version, KIMETSU_SCHEMA_VERSION
198        )
199        .into());
200    }
201
202    let conn = Connection::open_with_flags(&paths.brain_db, OpenFlags::SQLITE_OPEN_READ_ONLY)?;
203    schema::validate(&conn)?;
204    Ok((paths, config, conn))
205}
206
207pub struct BrainSession {
208    paths: ProjectPaths,
209    config: ProjectConfig,
210    conn: Connection,
211    /// v0.4.1: user-scope brain at `~/.kimetsu/brain.db`. Opened
212    /// lazily during session construction; `None` when the user
213    /// brain is disabled (`KIMETSU_USER_BRAIN=0`), no home dir is
214    /// resolvable, or — for the read-only constructor — the file
215    /// hasn't been created yet. Retrieval merges memories from this
216    /// connection alongside the project DB; repo files and manifests
217    /// stay project-only.
218    user_conn: Option<Connection>,
219    repo_root: String,
220}
221
222impl BrainSession {
223    pub fn open(start: &Path) -> KimetsuResult<Self> {
224        let (paths, config, conn) = load_project(start)?;
225        // Read/write user brain — created on demand so a v0.4 binary
226        // running on a v0.3 home dir provisions the file the first
227        // time the user actually writes a GlobalUser memory.
228        let user_conn = user_brain::open_user_brain()?;
229        Self::from_parts(paths, config, conn, user_conn)
230    }
231
232    pub fn open_readonly(start: &Path) -> KimetsuResult<Self> {
233        let (paths, config, conn) = load_project_readonly(start)?;
234        // Read-only path skips file creation — if the user brain
235        // doesn't exist yet we just retrieve from the project DB
236        // alone, no surprise file under $HOME.
237        let user_conn = user_brain::open_user_brain_readonly()?;
238        Self::from_parts(paths, config, conn, user_conn)
239    }
240
241    fn from_parts(
242        paths: ProjectPaths,
243        config: ProjectConfig,
244        conn: Connection,
245        user_conn: Option<Connection>,
246    ) -> KimetsuResult<Self> {
247        let repo_root = paths
248            .repo_root
249            .canonicalize()?
250            .to_string_lossy()
251            .to_string();
252        Ok(Self {
253            paths,
254            config,
255            conn,
256            user_conn,
257            repo_root,
258        })
259    }
260
261    pub fn retrieve_context(
262        &self,
263        stage: &str,
264        query: &str,
265        budget_tokens: u32,
266    ) -> KimetsuResult<ContextBundle> {
267        // v0.4.1: merge user-brain memories into the candidate set
268        // when the user DB is open. The multi-conn path normalizes
269        // both candidate streams together so a user-brain capsule
270        // and a project-brain capsule are comparable on the same
271        // `raw_relevance` scale before scoring.
272        let extras: Vec<&Connection> = self.user_conn.as_ref().into_iter().collect();
273        context::retrieve_context_multi(
274            &self.conn,
275            &self.repo_root,
276            &self.config.broker.weights,
277            ContextRequest {
278                stage: stage.to_string(),
279                query: query.to_string(),
280                budget_tokens,
281            },
282            &extras,
283        )
284    }
285
286    pub fn repo_root(&self) -> &Path {
287        &self.paths.repo_root
288    }
289
290    /// v0.4.1: expose the user-brain connection so callers (e.g.
291    /// `kimetsu brain status`) can report counts/paths without
292    /// re-opening the file. Returns None when the user brain is
293    /// disabled or unresolvable.
294    pub fn user_conn(&self) -> Option<&Connection> {
295        self.user_conn.as_ref()
296    }
297}
298
299pub fn load_config(paths: &ProjectPaths) -> KimetsuResult<ProjectConfig> {
300    let content = fs::read_to_string(&paths.project_toml).map_err(|err| {
301        format!(
302            "failed to read {}; run `kimetsu init` first: {err}",
303            paths.project_toml.display()
304        )
305    })?;
306    ProjectConfig::from_toml(&content)
307}
308
309pub fn config_text(start: &Path) -> KimetsuResult<String> {
310    let paths = ProjectPaths::discover(start)?;
311    Ok(fs::read_to_string(paths.project_toml)?)
312}
313
314pub fn list_runs(start: &Path) -> KimetsuResult<Vec<RunSummary>> {
315    let (_paths, _config, conn) = load_project(start)?;
316    let mut stmt = conn.prepare(
317        "
318        SELECT run_id, task, started_at, terminal_kind
319        FROM runs
320        ORDER BY started_at DESC
321        LIMIT 100
322        ",
323    )?;
324
325    let rows = stmt.query_map([], |row| {
326        Ok(RunSummary {
327            run_id: row.get(0)?,
328            task: row.get(1)?,
329            started_at: row.get(2)?,
330            terminal_kind: row.get(3)?,
331        })
332    })?;
333
334    let mut runs = Vec::new();
335    for row in rows {
336        runs.push(row?);
337    }
338    Ok(runs)
339}
340
341pub fn show_run(start: &Path, run_id: &str) -> KimetsuResult<Option<RunSummary>> {
342    let (_paths, _config, conn) = load_project(start)?;
343    let mut stmt = conn.prepare(
344        "
345        SELECT run_id, task, started_at, terminal_kind
346        FROM runs
347        WHERE run_id = ?1
348        ",
349    )?;
350
351    let mut rows = stmt.query(params![run_id])?;
352    if let Some(row) = rows.next()? {
353        Ok(Some(RunSummary {
354            run_id: row.get(0)?,
355            task: row.get(1)?,
356            started_at: row.get(2)?,
357            terminal_kind: row.get(3)?,
358        }))
359    } else {
360        Ok(None)
361    }
362}
363
364pub fn add_memory(
365    start: &Path,
366    scope: MemoryScope,
367    kind: MemoryKind,
368    text: &str,
369) -> KimetsuResult<String> {
370    // v0.4.5: redact secrets at the ingest boundary. The redaction
371    // pipeline catches Anthropic/OpenAI/GitHub/AWS/Slack/Google
372    // credentials, JWTs, PEM blocks, and generic `api_key=...` /
373    // `bearer ...` / `token: ...` assignments. A leak that lands in
374    // brain.db is durable, replicated across user / project scopes,
375    // and shows up in every retrieval — better to false-positive on
376    // a config string than to leak a real key.
377    //
378    // On a hit we replace the bytes with `[REDACTED:<kind>]` and
379    // print a one-liner to stderr so the operator notices. We do
380    // NOT fail the write: keeping the user memorable (the rest of
381    // the text) is more useful than rejecting outright.
382    let redaction = redact::redact_secrets(text);
383    if redaction.was_redacted() {
384        eprintln!("kimetsu-brain: {}", redaction.summary());
385    }
386    let text = redaction.text.as_str();
387
388    // v0.4.1: GlobalUser memories route to `~/.kimetsu/brain.db` when
389    // the user brain is enabled. The user-brain write path is
390    // intentionally simpler (no run rows, no trace events, no project
391    // lock) because there's no project to attribute them to.
392    //
393    // If the user brain is disabled (KIMETSU_USER_BRAIN=0) OR
394    // unreachable (no $HOME), fall through to the project DB so
395    // backward compat is preserved — existing scripts that wrote
396    // GlobalUser memories into the project keep working.
397    if scope == MemoryScope::GlobalUser
398        && let Some(user_conn) = user_brain::open_user_brain()?
399    {
400        return user_brain::add_user_memory(&user_conn, kind, text, 1.0);
401    }
402    let (paths, config, conn) = load_project(start)?;
403    let run_id = RunId::new();
404    let _lock = ProjectLock::acquire(&paths, "brain memory add", Some(run_id))?;
405    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
406    let memory_id = Ulid::new().to_string();
407    let normalized = normalize_memory_text(text);
408
409    // MP-17 #14: dedup. If an ACTIVE memory with the same scope + kind +
410    // normalized text already exists, return its ID without writing a
411    // duplicate. The scope/kind tuple keeps task-specific duplicates
412    // separate from global ones; the normalized form makes minor
413    // whitespace / punctuation differences collapse to the same row.
414    let existing: Option<String> = conn
415        .query_row(
416            "
417            SELECT memory_id FROM memories
418            WHERE scope = ?1 AND kind = ?2 AND normalized_text = ?3
419              AND invalidated_at IS NULL
420            LIMIT 1
421            ",
422            rusqlite::params![scope.to_string(), kind.to_string(), normalized],
423            |row| row.get::<_, String>(0),
424        )
425        .optional()?;
426    if let Some(existing_id) = existing {
427        return Ok(existing_id);
428    }
429
430    let started = Event::new(
431        run_id,
432        "run.started",
433        serde_json::json!({
434            "mode": "admin",
435            "task": "memory add",
436            "project_id": config.kimetsu.project_id,
437            "repo_root": paths.repo_root.to_string_lossy(),
438            "model": null,
439            "platform": std::env::consts::OS,
440            "kimetsu_version": env!("CARGO_PKG_VERSION"),
441            "config_hash": config_hash(&paths.project_toml)?,
442        }),
443    );
444    writer.append(&started, true)?;
445
446    let accepted = Event::new(
447        run_id,
448        "memory.accepted",
449        serde_json::json!({
450            "proposal_id": null,
451            "memory_id": memory_id,
452            "scope": scope.to_string(),
453            "kind": kind.to_string(),
454            "text": text,
455            "normalized_text": normalized,
456            "confidence": 1.0,
457            "provenance_snapshot": {
458                "source": "manual_cli",
459                "run_id": run_id.to_string(),
460                "text": text,
461            }
462        }),
463    );
464    writer.append(&accepted, true)?;
465
466    let finished = Event::new(
467        run_id,
468        "run.finished",
469        serde_json::json!({
470            "status": "success",
471            "final_report_path": null,
472            "total_cost_usd": 0.0,
473            "total_tool_calls": 0,
474        }),
475    );
476    writer.append(&finished, true)?;
477
478    projector::apply_events(&conn, &[started, accepted, finished])?;
479
480    // v0.4.2: post-projection embedding write. v0.4.3 wired the
481    // default embedder behind a feature flag — see
482    // `embeddings::open_default_embedder`. Default build: NoopEmbedder
483    // (column stays NULL, FTS only). `--features embeddings` build:
484    // fastembed-rs BGE-small by default, configurable via
485    // KIMETSU_BRAIN_EMBEDDER. The embedder is cached in a
486    // process-static OnceLock so we only pay model-load cost once.
487    let embedder = embeddings::open_default_embedder();
488    embeddings::embed_and_persist(&conn, &memory_id, text, embedder)?;
489
490    // v0.5.2: conflict detection at ingest. Scans for high-cosine,
491    // different-text neighbors in the same scope and logs each pair
492    // to `memory_conflicts` for operator review via
493    // `kimetsu brain memory conflicts`. Best-effort: NoopEmbedder
494    // (lean build) returns 0 hits; embedder failures degrade to a
495    // stderr line, never to a failed insert.
496    let conflicts = conflict::detect_and_record(
497        &conn,
498        &memory_id,
499        &scope,
500        &kind.to_string(),
501        text,
502        embedder,
503    );
504    if conflicts > 0 {
505        eprintln!(
506            "kimetsu-brain: memory {memory_id} conflicts with {conflicts} existing memor{} (run `kimetsu brain memory conflicts` to review)",
507            if conflicts == 1 { "y" } else { "ies" }
508        );
509    }
510
511    Ok(memory_id)
512}
513
514pub fn list_memories(start: &Path) -> KimetsuResult<Vec<MemoryRow>> {
515    let (_paths, _config, conn) = load_project(start)?;
516    let mut memories = list_memories_from_conn(&conn)?;
517    // v0.4.1: merge user-brain rows so the user sees their portable
518    // capsules alongside the per-repo ones. We read user brain via
519    // the read-only path (no file-create side-effect on list).
520    if let Some(user_conn) = user_brain::open_user_brain_readonly()? {
521        memories.extend(user_brain::list_user_memories(&user_conn)?);
522    }
523    Ok(memories)
524}
525
526/// v0.5.1: per-run memory attribution. Walks `memory_citations`,
527/// the run's `context.injected` events, and (when present) the
528/// terminal run.finished/failed/aborted event to produce a
529/// `BlameReport` that surfaces which memories the model actually
530/// reasoned with vs which were silent passengers.
531///
532/// Lookups across user + project brains are merged so a cited
533/// user-scope memory shows its text even when the run lived in a
534/// project brain.
535pub fn blame_run(start: &Path, run_id: &str) -> KimetsuResult<BlameReport> {
536    let (_paths, _config, conn) = load_project(start)?;
537    let user_conn = user_brain::open_user_brain_readonly()?;
538
539    // 1. Terminal outcome.
540    let (outcome, failure_category) = run_outcome(&conn, run_id)?;
541
542    // 2. Cited memories — ordered by turn.
543    let cited_rows: Vec<(String, i64, Option<String>, String)> = {
544        let mut stmt = conn.prepare(
545            "
546            SELECT memory_id, turn, rationale, cited_at
547            FROM memory_citations
548            WHERE run_id = ?1
549            ORDER BY turn ASC, cited_at ASC
550            ",
551        )?;
552        let rows = stmt.query_map(rusqlite::params![run_id], |row| {
553            Ok((
554                row.get::<_, String>(0)?,
555                row.get::<_, i64>(1)?,
556                row.get::<_, Option<String>>(2)?,
557                row.get::<_, String>(3)?,
558            ))
559        })?;
560        let mut out = Vec::new();
561        for row in rows {
562            out.push(row?);
563        }
564        out
565    };
566
567    let mut cited: Vec<CitedMemory> = Vec::with_capacity(cited_rows.len());
568    let mut cited_set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
569    for (memory_id, turn, rationale, cited_at) in cited_rows {
570        cited_set.insert(memory_id.clone());
571        let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
572        cited.push(CitedMemory {
573            memory_id,
574            turn,
575            rationale,
576            cited_at,
577            text_preview: text_preview(&text, 120),
578            scope,
579            kind,
580        });
581    }
582
583    // 3. Silent passengers — retrieved but not cited.
584    let retrieved_ids = collect_injected_memory_ids_for_blame(&conn, run_id)?;
585    let mut silent: Vec<SilentMemory> = Vec::new();
586    for memory_id in retrieved_ids {
587        if cited_set.contains(&memory_id) {
588            continue;
589        }
590        let (text, scope, kind) = resolve_memory(&conn, user_conn.as_ref(), &memory_id);
591        silent.push(SilentMemory {
592            memory_id,
593            text_preview: text_preview(&text, 120),
594            scope,
595            kind,
596        });
597    }
598
599    Ok(BlameReport {
600        run_id: run_id.to_string(),
601        outcome,
602        failure_category,
603        cited,
604        silent_passengers: silent,
605    })
606}
607
608fn run_outcome(
609    conn: &Connection,
610    run_id: &str,
611) -> KimetsuResult<(String, Option<String>)> {
612    // Pull the most recent terminal event for the run, if any.
613    let row: Option<(String, String)> = conn
614        .query_row(
615            "
616            SELECT kind, payload_json
617            FROM events
618            WHERE run_id = ?1
619              AND kind IN ('run.finished', 'run.failed', 'run.aborted')
620            ORDER BY ts DESC
621            LIMIT 1
622            ",
623            rusqlite::params![run_id],
624            |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)),
625        )
626        .optional()?;
627    Ok(match row {
628        Some((kind, payload_json)) => {
629            let outcome = match kind.as_str() {
630                "run.finished" => "success".to_string(),
631                "run.failed" => "failed".to_string(),
632                "run.aborted" => "aborted".to_string(),
633                other => other.to_string(),
634            };
635            let category = if kind == "run.failed" {
636                serde_json::from_str::<serde_json::Value>(&payload_json)
637                    .ok()
638                    .and_then(|v| {
639                        v.get("category")
640                            .and_then(|c| c.as_str())
641                            .map(str::to_string)
642                    })
643            } else {
644                None
645            };
646            (outcome, category)
647        }
648        None => ("unknown".to_string(), None),
649    })
650}
651
652fn collect_injected_memory_ids_for_blame(
653    conn: &Connection,
654    run_id: &str,
655) -> KimetsuResult<Vec<String>> {
656    let mut stmt = conn.prepare(
657        "
658        SELECT payload_json
659        FROM events
660        WHERE run_id = ?1 AND kind = 'context.injected'
661        ",
662    )?;
663    let rows = stmt.query_map(rusqlite::params![run_id], |row| row.get::<_, String>(0))?;
664    let mut seen = std::collections::BTreeSet::new();
665    for row in rows {
666        let payload_json = row?;
667        let payload: serde_json::Value = serde_json::from_str(&payload_json)?;
668        if let Some(ids) = payload.get("memory_ids").and_then(|v| v.as_array()) {
669            for id in ids {
670                if let Some(s) = id.as_str()
671                    && !s.is_empty()
672                {
673                    seen.insert(s.to_string());
674                }
675            }
676        }
677    }
678    Ok(seen.into_iter().collect())
679}
680
681/// Look up a memory's (text, scope, kind) across the project conn
682/// and the optional user-brain conn. Returns
683/// ("<unknown — deleted?>", "", "") when the row isn't found in
684/// either DB (e.g. invalidated + GC'd, or a typo'd memory_id in
685/// the citation).
686fn resolve_memory(
687    project_conn: &Connection,
688    user_conn: Option<&Connection>,
689    memory_id: &str,
690) -> (String, String, String) {
691    let q = "SELECT text, scope, kind FROM memories WHERE memory_id = ?1";
692    let try_conn = |conn: &Connection| -> Option<(String, String, String)> {
693        conn.query_row(q, rusqlite::params![memory_id], |row| {
694            Ok((
695                row.get::<_, String>(0)?,
696                row.get::<_, String>(1)?,
697                row.get::<_, String>(2)?,
698            ))
699        })
700        .optional()
701        .ok()
702        .flatten()
703    };
704    try_conn(project_conn)
705        .or_else(|| user_conn.and_then(try_conn))
706        .unwrap_or_else(|| {
707            (
708                "<unknown — deleted or invalid memory_id>".to_string(),
709                String::new(),
710                String::new(),
711            )
712        })
713}
714
715fn text_preview(text: &str, max_chars: usize) -> String {
716    let trimmed = text.trim();
717    if trimmed.chars().count() <= max_chars {
718        trimmed.to_string()
719    } else {
720        let head: String = trimmed.chars().take(max_chars).collect();
721        format!("{head}…")
722    }
723}
724
725fn list_memories_from_conn(conn: &Connection) -> KimetsuResult<Vec<MemoryRow>> {
726    let mut stmt = conn.prepare(
727        "
728        SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
729        FROM memories
730        ORDER BY created_at DESC
731        LIMIT 100
732        ",
733    )?;
734
735    let rows = stmt.query_map([], |row| {
736        Ok(MemoryRow {
737            memory_id: row.get(0)?,
738            scope: row.get(1)?,
739            kind: row.get(2)?,
740            text: row.get(3)?,
741            confidence: row.get(4)?,
742            use_count: row.get(5)?,
743            usefulness_score: row.get::<_, f64>(6)? as f32,
744        })
745    })?;
746
747    let mut memories = Vec::new();
748    for row in rows {
749        memories.push(row?);
750    }
751    Ok(memories)
752}
753
754/// MP-6: ranked list of memories sorted by the same usefulness ratio the
755/// broker uses for retrieval scoring (`usefulness_score / use_count`).
756/// Filters out invalidated rows and any memory with `use_count < min_uses`
757/// (the small-sample guard; default 3 matches the broker's
758/// SMALL_SAMPLE_THRESHOLD). Optional scope filter narrows to a single
759/// memory class. Lets the user see which memories are actually doing
760/// work so they can prune the rest with `memory prune`.
761#[derive(Debug, Clone, Default)]
762pub struct TopOptions {
763    pub scope: Option<String>,
764    pub min_uses: u32,
765    pub limit: u32,
766}
767
768pub fn list_memories_top(start: &Path, opts: TopOptions) -> KimetsuResult<Vec<MemoryRow>> {
769    let (_paths, _config, conn) = load_project(start)?;
770    let min_uses = opts.min_uses.max(1) as i64;
771    let limit = if opts.limit == 0 { 20 } else { opts.limit } as i64;
772
773    let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref() {
774        (
775            "
776            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
777            FROM memories
778            WHERE invalidated_at IS NULL
779              AND use_count >= ?1
780              AND lower(scope) = lower(?2)
781            ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
782            LIMIT ?3
783            ",
784            Some(scope.to_string()),
785        )
786    } else {
787        (
788            "
789            SELECT memory_id, scope, kind, text, confidence, use_count, usefulness_score
790            FROM memories
791            WHERE invalidated_at IS NULL
792              AND use_count >= ?1
793            ORDER BY (usefulness_score / CAST(use_count AS REAL)) DESC, use_count DESC
794            LIMIT ?2
795            ",
796            None,
797        )
798    };
799
800    let mut stmt = conn.prepare(sql)?;
801    let mut rows = if let Some(scope) = scope_param {
802        stmt.query_map(params![min_uses, scope, limit], map_memory_row)?
803            .collect::<Result<Vec<_>, _>>()?
804    } else {
805        stmt.query_map(params![min_uses, limit], map_memory_row)?
806            .collect::<Result<Vec<_>, _>>()?
807    };
808
809    // SQLite's NaN-from-zero protection: a freshly-created memory with
810    // use_count=0 would division-zero, but the WHERE clause guards
811    // min_uses >= 1, so we never see a NaN here. Sort is a defensive
812    // tie-breaker only.
813    rows.sort_by(|a, b| {
814        let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
815        let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
816        rb.partial_cmp(&ra).unwrap_or(std::cmp::Ordering::Equal)
817    });
818    Ok(rows)
819}
820
821fn map_memory_row(row: &rusqlite::Row) -> rusqlite::Result<MemoryRow> {
822    Ok(MemoryRow {
823        memory_id: row.get(0)?,
824        scope: row.get(1)?,
825        kind: row.get(2)?,
826        text: row.get(3)?,
827        confidence: row.get(4)?,
828        use_count: row.get(5)?,
829        usefulness_score: row.get::<_, f64>(6)? as f32,
830    })
831}
832
833/// MP-6: bulk prune of memories whose outcome-attribution data says they
834/// are net-negative. Selection rules:
835///   use_count >= min_uses
836///   usefulness_score / use_count <= max_ratio
837///   invalidated_at IS NULL
838///   scope filter optional
839///
840/// `apply = false` is the default at the CLI layer so the user sees
841/// what would be touched before any writes. `apply = true` invalidates
842/// each match via the existing `invalidate_memory` path so every
843/// removal still emits a canonical `memory.invalidated` event.
844#[derive(Debug, Clone)]
845pub struct PruneOptions {
846    pub scope: Option<String>,
847    pub min_uses: u32,
848    pub max_ratio: f32,
849    pub apply: bool,
850}
851
852impl Default for PruneOptions {
853    fn default() -> Self {
854        Self {
855            scope: None,
856            min_uses: 3,
857            max_ratio: -0.2,
858            apply: false,
859        }
860    }
861}
862
863#[derive(Debug, Clone)]
864pub struct PruneCandidate {
865    pub memory_id: String,
866    pub scope: String,
867    pub kind: String,
868    pub use_count: u32,
869    pub usefulness_score: f32,
870    pub text: String,
871}
872
873#[derive(Debug, Clone, Default)]
874pub struct PruneSummary {
875    pub candidates: Vec<PruneCandidate>,
876    pub invalidated: u32,
877    pub failed: u32,
878}
879
880pub fn prune_low_usefulness(start: &Path, opts: PruneOptions) -> KimetsuResult<PruneSummary> {
881    let min_uses = opts.min_uses.max(1) as i64;
882
883    let candidates = {
884        let (_paths, _config, conn) = load_project(start)?;
885        let (sql, scope_param): (&str, Option<String>) = if let Some(scope) = opts.scope.as_deref()
886        {
887            (
888                "
889                SELECT memory_id, scope, kind, text, use_count, usefulness_score
890                FROM memories
891                WHERE invalidated_at IS NULL
892                  AND use_count >= ?1
893                  AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
894                  AND lower(scope) = lower(?3)
895                ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
896                ",
897                Some(scope.to_string()),
898            )
899        } else {
900            (
901                "
902                SELECT memory_id, scope, kind, text, use_count, usefulness_score
903                FROM memories
904                WHERE invalidated_at IS NULL
905                  AND use_count >= ?1
906                  AND (usefulness_score / CAST(use_count AS REAL)) <= ?2
907                ORDER BY (usefulness_score / CAST(use_count AS REAL)) ASC
908                ",
909                None,
910            )
911        };
912        let mut stmt = conn.prepare(sql)?;
913        let max_ratio = opts.max_ratio as f64;
914        let mut found: Vec<PruneCandidate> = if let Some(scope) = scope_param {
915            stmt.query_map(params![min_uses, max_ratio, scope], |row| {
916                Ok(PruneCandidate {
917                    memory_id: row.get(0)?,
918                    scope: row.get(1)?,
919                    kind: row.get(2)?,
920                    text: row.get(3)?,
921                    use_count: row.get(4)?,
922                    usefulness_score: row.get::<_, f64>(5)? as f32,
923                })
924            })?
925            .collect::<Result<Vec<_>, _>>()?
926        } else {
927            stmt.query_map(params![min_uses, max_ratio], |row| {
928                Ok(PruneCandidate {
929                    memory_id: row.get(0)?,
930                    scope: row.get(1)?,
931                    kind: row.get(2)?,
932                    text: row.get(3)?,
933                    use_count: row.get(4)?,
934                    usefulness_score: row.get::<_, f64>(5)? as f32,
935                })
936            })?
937            .collect::<Result<Vec<_>, _>>()?
938        };
939        // Stable tie-break: lowest ratio first, then highest use_count
940        // first (penalize the long-running underperformers).
941        found.sort_by(|a, b| {
942            let ra = a.usefulness_score as f64 / a.use_count.max(1) as f64;
943            let rb = b.usefulness_score as f64 / b.use_count.max(1) as f64;
944            ra.partial_cmp(&rb)
945                .unwrap_or(std::cmp::Ordering::Equal)
946                .then_with(|| b.use_count.cmp(&a.use_count))
947        });
948        found
949    };
950
951    let mut summary = PruneSummary {
952        candidates: candidates.clone(),
953        invalidated: 0,
954        failed: 0,
955    };
956    if !opts.apply {
957        return Ok(summary);
958    }
959
960    for candidate in &candidates {
961        let ratio = candidate.usefulness_score / candidate.use_count.max(1) as f32;
962        let reason = format!(
963            "pruned_by_usefulness ratio={:+.2} use_count={}",
964            ratio, candidate.use_count
965        );
966        match invalidate_memory(start, &candidate.memory_id, Some(&reason)) {
967            Ok(()) => summary.invalidated += 1,
968            Err(_) => summary.failed += 1,
969        }
970    }
971    Ok(summary)
972}
973
974pub fn list_proposals(start: &Path, filter: ProposalFilter) -> KimetsuResult<Vec<ProposalRow>> {
975    let (_paths, _config, conn) = load_project(start)?;
976    let mut sql = String::from(
977        "
978        SELECT proposal_id, run_id, scope, kind, text, rationale,
979               proposed_confidence, status, decided_reason
980        FROM memory_proposals
981        ",
982    );
983    let mut clauses = Vec::<String>::new();
984    let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
985    if let Some(scope) = filter.scope.as_deref() {
986        clauses.push("scope = ?".to_string());
987        params.push(Box::new(scope.to_string()));
988    }
989    if let Some(kind) = filter.kind.as_deref() {
990        clauses.push("kind = ?".to_string());
991        params.push(Box::new(kind.to_string()));
992    }
993    if let Some(run_id) = filter.from_run.as_deref() {
994        clauses.push("run_id = ?".to_string());
995        params.push(Box::new(run_id.to_string()));
996    }
997    if let Some(min_conf) = filter.min_confidence {
998        clauses.push("proposed_confidence >= ?".to_string());
999        params.push(Box::new(min_conf as f64));
1000    }
1001    if let Some(status) = filter.status.as_deref()
1002        && !status.eq_ignore_ascii_case("any")
1003    {
1004        clauses.push("status = ?".to_string());
1005        params.push(Box::new(status.to_string()));
1006    }
1007    if !clauses.is_empty() {
1008        sql.push_str(" WHERE ");
1009        sql.push_str(&clauses.join(" AND "));
1010    }
1011    let limit = if filter.limit == 0 { 100 } else { filter.limit };
1012    sql.push_str(&format!(" ORDER BY rowid DESC LIMIT {limit}"));
1013
1014    let mut stmt = conn.prepare(&sql)?;
1015    let param_refs: Vec<&dyn rusqlite::ToSql> = params.iter().map(|p| p.as_ref()).collect();
1016    let rows = stmt.query_map(param_refs.as_slice(), |row| {
1017        Ok(ProposalRow {
1018            proposal_id: row.get(0)?,
1019            run_id: row.get(1)?,
1020            scope: row.get(2)?,
1021            kind: row.get(3)?,
1022            text: row.get(4)?,
1023            rationale: row.get(5)?,
1024            proposed_confidence: row.get(6)?,
1025            status: row.get(7)?,
1026            decided_reason: row.get(8)?,
1027        })
1028    })?;
1029
1030    let mut proposals = Vec::new();
1031    for row in rows {
1032        proposals.push(row?);
1033    }
1034    Ok(proposals)
1035}
1036
1037pub fn ingest_repo(start: &Path) -> KimetsuResult<RepoIngestSummary> {
1038    let (paths, config, conn) = load_project(start)?;
1039    let run_id = RunId::new();
1040    let _lock = ProjectLock::acquire(&paths, "brain ingest-repo", Some(run_id))?;
1041    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1042
1043    let started = admin_started_event(&paths, &config, run_id, "repo ingest")?;
1044    writer.append(&started, true)?;
1045
1046    let summary = ingest::ingest_repo(&conn, &paths, &config)?;
1047
1048    let ingested = Event::new(
1049        run_id,
1050        "repo.ingested",
1051        serde_json::json!({
1052            "repo_root": summary.repo_root.to_string_lossy(),
1053            "indexed_files": summary.indexed_files,
1054            "skipped_files": summary.skipped_files,
1055            "manifests": summary.manifests,
1056        }),
1057    );
1058    writer.append(&ingested, true)?;
1059
1060    let finished = admin_finished_event(run_id);
1061    writer.append(&finished, true)?;
1062    projector::apply_events(&conn, &[started, ingested, finished])?;
1063
1064    Ok(summary)
1065}
1066
1067pub fn search_files(
1068    start: &Path,
1069    query: &str,
1070    limit: u32,
1071) -> KimetsuResult<Vec<context::ContextCapsule>> {
1072    let (paths, _config, conn) = load_project(start)?;
1073    let repo_root = paths
1074        .repo_root
1075        .canonicalize()?
1076        .to_string_lossy()
1077        .to_string();
1078    context::search_repo_files(&conn, &repo_root, query, limit)
1079}
1080
1081pub fn retrieve_context(
1082    start: &Path,
1083    stage: &str,
1084    query: &str,
1085    budget_tokens: u32,
1086) -> KimetsuResult<ContextBundle> {
1087    BrainSession::open(start)?.retrieve_context(stage, query, budget_tokens)
1088}
1089
1090pub fn retrieve_context_readonly(
1091    start: &Path,
1092    stage: &str,
1093    query: &str,
1094    budget_tokens: u32,
1095) -> KimetsuResult<ContextBundle> {
1096    BrainSession::open_readonly(start)?.retrieve_context(stage, query, budget_tokens)
1097}
1098
1099#[allow(clippy::too_many_arguments)]
1100pub fn retrieve_benchmark_context_readonly(
1101    start: &Path,
1102    task: &str,
1103    dataset: &str,
1104    task_slug: Option<&str>,
1105    warm_policy: benchmark::BenchmarkWarmPolicy,
1106    stage: &str,
1107    budget_tokens: u32,
1108    require_benchmark_memory: bool,
1109    max_capsules: usize,
1110) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1111    retrieve_benchmark_context_readonly_with_ambient(
1112        start,
1113        task,
1114        dataset,
1115        task_slug,
1116        warm_policy,
1117        stage,
1118        budget_tokens,
1119        require_benchmark_memory,
1120        max_capsules,
1121        None,
1122    )
1123}
1124
1125/// v0.4.4: variant that appends an optional ambient-context suffix to
1126/// the canonical benchmark query AFTER slug detection. Used by the
1127/// MCP `kimetsu_benchmark_context` tool so the workspace fingerprint
1128/// (git branch, dirty files, recent edits) contributes to retrieval
1129/// without corrupting the slug parser.
1130#[allow(clippy::too_many_arguments)]
1131pub fn retrieve_benchmark_context_readonly_with_ambient(
1132    start: &Path,
1133    task: &str,
1134    dataset: &str,
1135    task_slug: Option<&str>,
1136    warm_policy: benchmark::BenchmarkWarmPolicy,
1137    stage: &str,
1138    budget_tokens: u32,
1139    require_benchmark_memory: bool,
1140    max_capsules: usize,
1141    ambient_suffix: Option<&str>,
1142) -> KimetsuResult<benchmark::BenchmarkBrainContext> {
1143    let normalized_slug = task_slug
1144        .and_then(benchmark::normalize_task_slug)
1145        .or_else(|| benchmark::normalize_task_slug(task));
1146    let mut query =
1147        benchmark::benchmark_query(task, dataset, normalized_slug.as_deref(), warm_policy);
1148    if let Some(suffix) = ambient_suffix.filter(|s| !s.trim().is_empty()) {
1149        query.push_str(suffix);
1150    }
1151    let bundle =
1152        BrainSession::open_readonly(start)?.retrieve_context(stage, &query, budget_tokens)?;
1153    Ok(benchmark::build_benchmark_context(
1154        bundle,
1155        task,
1156        dataset,
1157        &query,
1158        normalized_slug,
1159        warm_policy,
1160        require_benchmark_memory,
1161        max_capsules,
1162    ))
1163}
1164
1165pub fn record_benchmark_outcome(
1166    start: &Path,
1167    outcome: benchmark::BenchmarkOutcome,
1168) -> KimetsuResult<RecordedBenchmarkOutcome> {
1169    let task_slug = outcome
1170        .task_slug
1171        .clone()
1172        .or_else(|| benchmark::normalize_task_slug(&outcome.task));
1173    let kind = benchmark::outcome_memory_kind(&outcome);
1174    let text = benchmark::outcome_memory_text(&outcome);
1175    let memory_id = add_memory(start, MemoryScope::GlobalUser, kind, &text)?;
1176    let (proposal_id, proposal_text) = match outcome.generalization.as_ref() {
1177        Some(proposal) if proposal.role.is_generalizable() => {
1178            let (proposal_id, proposal_text) = propose_benchmark_memory(start, &outcome, proposal)?;
1179            (Some(proposal_id), Some(proposal_text))
1180        }
1181        _ => (None, None),
1182    };
1183    Ok(RecordedBenchmarkOutcome {
1184        memory_id,
1185        task_slug,
1186        kind,
1187        text,
1188        proposal_id,
1189        proposal_text,
1190    })
1191}
1192
1193fn propose_benchmark_memory(
1194    start: &Path,
1195    outcome: &benchmark::BenchmarkOutcome,
1196    proposal: &benchmark::BenchmarkMemoryProposal,
1197) -> KimetsuResult<(String, String)> {
1198    let (paths, config, conn) = load_project(start)?;
1199    let run_id = RunId::new();
1200    let _lock = ProjectLock::acquire(&paths, "benchmark memory proposal", Some(run_id))?;
1201    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1202    let proposal_id = Ulid::new().to_string();
1203    // v0.4.5: redact secrets in the proposal text + rationale before
1204    // they hit the trace + memory_proposals table. Benchmark outcomes
1205    // pull from tool output, which is exactly where a model-leaked
1206    // token would surface.
1207    let raw_text = benchmark::proposal_memory_text(outcome, proposal);
1208    let text_redaction = redact::redact_secrets(&raw_text);
1209    if text_redaction.was_redacted() {
1210        eprintln!(
1211            "kimetsu-brain (benchmark proposal): {}",
1212            text_redaction.summary()
1213        );
1214    }
1215    let text = text_redaction.text;
1216    let kind = benchmark::proposal_memory_kind(proposal);
1217    let rationale_raw = if proposal.rationale.trim().is_empty() {
1218        "generalized from benchmark outcome".to_string()
1219    } else {
1220        proposal.rationale.trim().to_string()
1221    };
1222    let rationale = redact::redact_secrets(&rationale_raw).text;
1223
1224    let started = admin_started_event(&paths, &config, run_id, "benchmark memory proposal")?;
1225    writer.append(&started, true)?;
1226
1227    let proposed = Event::new(
1228        run_id,
1229        "memory.proposed",
1230        serde_json::json!({
1231            "proposal_id": proposal_id,
1232            "scope": "global_user",
1233            "kind": kind.to_string(),
1234            "text": text,
1235            "rationale": rationale,
1236            "proposed_confidence": proposal.confidence.clamp(0.0, 1.0),
1237            "source_event_ids": [],
1238        }),
1239    );
1240    writer.append(&proposed, true)?;
1241
1242    let finished = admin_finished_event(run_id);
1243    writer.append(&finished, true)?;
1244
1245    projector::apply_events(&conn, &[started, proposed, finished])?;
1246    Ok((proposal_id, text))
1247}
1248
1249pub fn accept_proposal(
1250    start: &Path,
1251    proposal_id: &str,
1252    overrides: AcceptOverrides,
1253) -> KimetsuResult<String> {
1254    let (paths, config, conn) = load_project(start)?;
1255    let proposal = load_pending_proposal(&conn, proposal_id)?;
1256    let run_id = RunId::new();
1257    let _lock = ProjectLock::acquire(&paths, "brain memory accept", Some(run_id))?;
1258    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1259    let memory_id = Ulid::new().to_string();
1260    let normalized = normalize_memory_text(&proposal.text);
1261
1262    let resolved_scope = match overrides.scope.as_deref() {
1263        Some(value) if !value.trim().is_empty() => value.trim().to_string(),
1264        _ => proposal.scope.clone(),
1265    };
1266    let resolved_confidence = overrides
1267        .confidence
1268        .map(|c| c.clamp(0.0, 1.0))
1269        .unwrap_or(proposal.proposed_confidence);
1270
1271    let started = admin_started_event(&paths, &config, run_id, "memory accept")?;
1272    writer.append(&started, true)?;
1273
1274    let accepted = Event::new(
1275        run_id,
1276        "memory.accepted",
1277        serde_json::json!({
1278            "proposal_id": proposal.proposal_id,
1279            "memory_id": memory_id,
1280            "scope": resolved_scope,
1281            "kind": proposal.kind,
1282            "text": proposal.text,
1283            "normalized_text": normalized,
1284            "confidence": resolved_confidence,
1285            "provenance_snapshot": {
1286                "source": "memory_proposal",
1287                "proposal_id": proposal.proposal_id,
1288                "source_run_id": proposal.run_id,
1289                "scope_override": overrides.scope.clone(),
1290                "confidence_override": overrides.confidence,
1291            }
1292        }),
1293    );
1294    writer.append(&accepted, true)?;
1295
1296    let finished = admin_finished_event(run_id);
1297    writer.append(&finished, true)?;
1298
1299    projector::apply_events(&conn, &[started, accepted.clone(), finished])?;
1300    conn.execute(
1301        "
1302        UPDATE memory_proposals
1303        SET status = 'accepted',
1304            decided_at = ?2,
1305            decided_by = 'cli'
1306        WHERE proposal_id = ?1
1307        ",
1308        params![
1309            proposal_id,
1310            accepted
1311                .ts
1312                .format(&time::format_description::well_known::Rfc3339)?
1313        ],
1314    )?;
1315
1316    Ok(memory_id)
1317}
1318
1319/// MP-4d: human override that flags an accepted memory so the broker stops
1320/// surfacing it. Emits a `memory.invalidated` event and projects it. The
1321/// canonical trace keeps the original `memory.accepted`; invalidation is
1322/// purely additive metadata. Idempotent — re-invalidating a memory just
1323/// overwrites the timestamp/reason.
1324pub fn invalidate_memory(start: &Path, memory_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1325    let (paths, config, conn) = load_project(start)?;
1326    let exists: i64 = conn.query_row(
1327        "SELECT COUNT(*) FROM memories WHERE memory_id = ?1",
1328        params![memory_id],
1329        |row| row.get(0),
1330    )?;
1331    if exists == 0 {
1332        return Err(format!("memory not found: {memory_id}").into());
1333    }
1334
1335    let run_id = RunId::new();
1336    let _lock = ProjectLock::acquire(&paths, "brain memory invalidate", Some(run_id))?;
1337    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1338
1339    let resolved_reason = reason
1340        .and_then(|s| {
1341            let trimmed = s.trim();
1342            if trimmed.is_empty() {
1343                None
1344            } else {
1345                Some(trimmed.to_string())
1346            }
1347        })
1348        .unwrap_or_else(|| "invalidated_by_cli".to_string());
1349
1350    let started = admin_started_event(&paths, &config, run_id, "memory invalidate")?;
1351    writer.append(&started, true)?;
1352
1353    let invalidated = Event::new(
1354        run_id,
1355        "memory.invalidated",
1356        serde_json::json!({
1357            "memory_id": memory_id,
1358            "reason": resolved_reason,
1359        }),
1360    );
1361    writer.append(&invalidated, true)?;
1362
1363    let finished = admin_finished_event(run_id);
1364    writer.append(&finished, true)?;
1365
1366    projector::apply_events(&conn, &[started, invalidated, finished])?;
1367    Ok(())
1368}
1369
1370pub fn reject_proposal(start: &Path, proposal_id: &str, reason: Option<&str>) -> KimetsuResult<()> {
1371    let (paths, config, conn) = load_project(start)?;
1372    let _proposal = load_pending_proposal(&conn, proposal_id)?;
1373    let run_id = RunId::new();
1374    let _lock = ProjectLock::acquire(&paths, "brain memory reject", Some(run_id))?;
1375    let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id)?;
1376
1377    let resolved_reason = reason
1378        .and_then(|s| {
1379            let trimmed = s.trim();
1380            if trimmed.is_empty() {
1381                None
1382            } else {
1383                Some(trimmed.to_string())
1384            }
1385        })
1386        .unwrap_or_else(|| "rejected_by_cli".to_string());
1387
1388    let started = admin_started_event(&paths, &config, run_id, "memory reject")?;
1389    writer.append(&started, true)?;
1390
1391    let rejected = Event::new(
1392        run_id,
1393        "memory.rejected",
1394        serde_json::json!({
1395            "proposal_id": proposal_id,
1396            "reason": resolved_reason,
1397        }),
1398    );
1399    writer.append(&rejected, true)?;
1400
1401    let finished = admin_finished_event(run_id);
1402    writer.append(&finished, true)?;
1403
1404    projector::apply_events(&conn, &[started, rejected, finished])?;
1405    Ok(())
1406}
1407
1408pub fn rebuild_projection(start: &Path) -> KimetsuResult<usize> {
1409    let (paths, _config, conn) = load_project(start)?;
1410    let _lock = ProjectLock::acquire(&paths, "brain rebuild", None)?;
1411    let events = trace::read_all_traces(&paths)?;
1412    projector::rebuild(&conn, &events)?;
1413    Ok(events.len())
1414}
1415
1416pub fn clear_lock(start: &Path) -> KimetsuResult<bool> {
1417    let paths = ProjectPaths::discover(start)?;
1418    crate::lock::clear_force(&paths)
1419}
1420
1421/// v0.5.2: list open conflict-detection hits across the project brain
1422/// and (when enabled) the user brain. Each `ConflictReport` carries a
1423/// `source` label so the CLI can render which brain originated it —
1424/// resolve takes a separate code path per brain since the row only
1425/// lives in one DB.
1426#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1427pub struct ScopedConflict {
1428    /// Either "project" or "user". Determines which DB `resolve_conflict`
1429    /// must target when the operator chooses to apply a resolution.
1430    pub source: String,
1431    #[serde(flatten)]
1432    pub report: conflict::ConflictReport,
1433}
1434
1435/// Merge open conflicts from project + user brains. `limit` is applied
1436/// per-brain, so the worst case is `limit * 2` rows returned — the CLI
1437/// can re-truncate on display if needed.
1438pub fn list_conflicts(start: &Path, limit: u32) -> KimetsuResult<Vec<ScopedConflict>> {
1439    let mut out = Vec::new();
1440    let (_paths, _config, project_conn) = load_project_readonly(start)?;
1441    for report in conflict::list_unresolved_conflicts(&project_conn, limit)? {
1442        out.push(ScopedConflict {
1443            source: "project".to_string(),
1444            report,
1445        });
1446    }
1447    if let Some(user_conn) = user_brain::open_user_brain_readonly()? {
1448        for report in conflict::list_unresolved_conflicts(&user_conn, limit)? {
1449            out.push(ScopedConflict {
1450                source: "user".to_string(),
1451                report,
1452            });
1453        }
1454    }
1455    out.sort_by(|a, b| b.report.detected_at.cmp(&a.report.detected_at));
1456    Ok(out)
1457}
1458
1459/// Resolve a single open conflict by id with one of `kept_new`,
1460/// `kept_existing`, or `kept_both`. The conflict can live in either
1461/// the project brain or the user brain — we try project first, and on
1462/// "not found" fall through to user. Returns Ok(true) if a row was
1463/// updated.
1464///
1465/// We deliberately don't emit a `memory.invalidated` trace event here
1466/// even though `kept_new` / `kept_existing` invalidates one side. The
1467/// `memory_conflicts` row IS the audit trail; double-recording would
1468/// duplicate state across two systems. Operators who want the trace-
1469/// event-style record can use `kimetsu brain memory invalidate` instead.
1470pub fn resolve_conflict(
1471    start: &Path,
1472    conflict_id: &str,
1473    resolution: &str,
1474) -> KimetsuResult<bool> {
1475    let (paths, _config, project_conn) = load_project(start)?;
1476    let _lock = ProjectLock::acquire(&paths, "brain memory conflict resolve", None)?;
1477    if conflict::resolve_conflict(&project_conn, conflict_id, resolution)? {
1478        return Ok(true);
1479    }
1480    drop(project_conn); // release before opening user brain (avoid pseudo-conflict on flock semantics)
1481    if let Some(user_conn) = user_brain::open_user_brain()? {
1482        return conflict::resolve_conflict(&user_conn, conflict_id, resolution);
1483    }
1484    Ok(false)
1485}
1486
1487fn load_pending_proposal(conn: &Connection, proposal_id: &str) -> KimetsuResult<ProposalRow> {
1488    let mut stmt = conn.prepare(
1489        "
1490        SELECT proposal_id, run_id, scope, kind, text, rationale,
1491               proposed_confidence, status
1492        FROM memory_proposals
1493        WHERE proposal_id = ?1
1494        ",
1495    )?;
1496    let mut rows = stmt.query(params![proposal_id])?;
1497    let Some(row) = rows.next()? else {
1498        return Err(format!("memory proposal not found: {proposal_id}").into());
1499    };
1500
1501    let proposal = ProposalRow {
1502        proposal_id: row.get(0)?,
1503        run_id: row.get(1)?,
1504        scope: row.get(2)?,
1505        kind: row.get(3)?,
1506        text: row.get(4)?,
1507        rationale: row.get(5)?,
1508        proposed_confidence: row.get(6)?,
1509        status: row.get(7)?,
1510        decided_reason: None,
1511    };
1512
1513    if proposal.status != "pending" {
1514        return Err(format!(
1515            "memory proposal {proposal_id} is {}, not pending",
1516            proposal.status
1517        )
1518        .into());
1519    }
1520
1521    Ok(proposal)
1522}
1523
1524fn admin_started_event(
1525    paths: &ProjectPaths,
1526    config: &ProjectConfig,
1527    run_id: RunId,
1528    task: &str,
1529) -> KimetsuResult<Event> {
1530    Ok(Event::new(
1531        run_id,
1532        "run.started",
1533        serde_json::json!({
1534            "mode": "admin",
1535            "task": task,
1536            "project_id": config.kimetsu.project_id,
1537            "repo_root": paths.repo_root.to_string_lossy(),
1538            "model": null,
1539            "platform": std::env::consts::OS,
1540            "kimetsu_version": env!("CARGO_PKG_VERSION"),
1541            "config_hash": config_hash(&paths.project_toml)?,
1542        }),
1543    ))
1544}
1545
1546fn admin_finished_event(run_id: RunId) -> Event {
1547    Event::new(
1548        run_id,
1549        "run.finished",
1550        serde_json::json!({
1551            "status": "success",
1552            "final_report_path": null,
1553            "total_cost_usd": 0.0,
1554            "total_tool_calls": 0,
1555        }),
1556    )
1557}
1558
1559fn config_hash(path: &Path) -> KimetsuResult<String> {
1560    let bytes = fs::read(path)?;
1561    Ok(blake3::hash(&bytes).to_hex().to_string())
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566    use std::fs;
1567
1568    use super::*;
1569    // v0.4.1: pre-v0.4 tests assume `MemoryScope::GlobalUser` writes
1570    // land in the project DB. With user-brain routing on by default
1571    // that's no longer true — wrap each affected test in
1572    // `with_user_brain_disabled` so it sees v0.3.5 routing. Tests
1573    // that specifically exercise the user-brain path live in
1574    // `user_brain::tests` and opt-in via `with_user_brain_at`.
1575    use crate::user_brain::with_user_brain_disabled;
1576
1577    #[test]
1578    fn memory_add_survives_projection_rebuild_from_trace() {
1579        with_user_brain_disabled(|| {
1580            let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1581            fs::create_dir_all(&root).expect("create temp project");
1582
1583            init_project(&root, false).expect("init project");
1584            let memory_id = add_memory(
1585                &root,
1586                MemoryScope::GlobalUser,
1587                MemoryKind::Preference,
1588                "User prefers Rust for core infrastructure.",
1589            )
1590            .expect("add memory");
1591
1592            let memories = list_memories(&root).expect("list memories");
1593            assert_eq!(memories.len(), 1);
1594            assert_eq!(memories[0].memory_id, memory_id);
1595
1596            let event_count = rebuild_projection(&root).expect("rebuild projection");
1597            assert_eq!(event_count, 3);
1598
1599            let memories = list_memories(&root).expect("list rebuilt memories");
1600            assert_eq!(memories.len(), 1);
1601            assert_eq!(memories[0].memory_id, memory_id);
1602            assert_eq!(
1603                memories[0].text,
1604                "User prefers Rust for core infrastructure."
1605            );
1606
1607            fs::remove_dir_all(root).expect("remove temp project");
1608        });
1609    }
1610
1611    /// v0.4.5 end-to-end: secrets in `add_memory` text never reach
1612    /// brain.db. The redacted row keeps the surrounding context so
1613    /// the memory is still useful — only the credential is scrubbed.
1614    #[test]
1615    fn add_memory_redacts_secrets_before_persist() {
1616        with_user_brain_disabled(|| {
1617            let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1618            fs::create_dir_all(&root).expect("create temp project");
1619            init_project(&root, false).expect("init project");
1620
1621            let raw = "Add CLAUDE_CODE_OAUTH_TOKEN=sk-ant-api03-AbCdEfGhIjKlMnOpQrStUv0123456789AbCdEf to .env";
1622            let memory_id = add_memory(
1623                &root,
1624                MemoryScope::Repo,
1625                MemoryKind::Command,
1626                raw,
1627            )
1628            .expect("add memory");
1629
1630            let memories = list_memories(&root).expect("list");
1631            let stored = memories
1632                .iter()
1633                .find(|m| m.memory_id == memory_id)
1634                .expect("memory present");
1635            assert!(
1636                !stored.text.contains("sk-ant-api03"),
1637                "raw secret must NOT survive to brain.db: {}",
1638                stored.text
1639            );
1640            assert!(
1641                stored.text.contains("[REDACTED:anthropic_oauth]"),
1642                "placeholder must be present: {}",
1643                stored.text
1644            );
1645            assert!(
1646                stored.text.contains("CLAUDE_CODE_OAUTH_TOKEN")
1647                    && stored.text.contains(".env"),
1648                "non-secret context must be preserved: {}",
1649                stored.text
1650            );
1651
1652            fs::remove_dir_all(root).expect("cleanup");
1653        });
1654    }
1655
1656    #[test]
1657    fn repo_ingest_indexes_searchable_files_and_context_capsules() {
1658        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1659        fs::create_dir_all(root.join("src")).expect("create src");
1660        fs::create_dir_all(root.join("target")).expect("create target");
1661        fs::write(
1662            root.join("Cargo.toml"),
1663            "[package]\nname = \"fixture\"\nversion = \"0.1.0\"\n",
1664        )
1665        .expect("write manifest");
1666        fs::write(
1667            root.join("src").join("lib.rs"),
1668            "pub fn rebuild_projection_memory() -> &'static str { \"projection rebuild\" }\n",
1669        )
1670        .expect("write source");
1671        fs::write(
1672            root.join("target").join("generated.rs"),
1673            "projection rebuild",
1674        )
1675        .expect("write skipped");
1676        fs::write(root.join(".env"), "TOKEN=secret").expect("write secret");
1677        fs::write(root.join("blob.bin"), b"abc\0def").expect("write binary");
1678
1679        init_project(&root, false).expect("init project");
1680        add_memory(
1681            &root,
1682            MemoryScope::GlobalUser,
1683            MemoryKind::Preference,
1684            "User prefers Rust for core infrastructure.",
1685        )
1686        .expect("add memory");
1687
1688        let summary = ingest_repo(&root).expect("ingest repo");
1689        assert_eq!(summary.indexed_files, 2);
1690        assert_eq!(summary.manifests, 1);
1691
1692        let matches = search_files(&root, "projection rebuild", 5).expect("search files");
1693        assert!(
1694            matches
1695                .iter()
1696                .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
1697            "expected src/lib.rs in search results: {matches:?}"
1698        );
1699        assert!(
1700            matches
1701                .iter()
1702                .all(|capsule| !capsule.expansion_handle.contains("target/")),
1703            "target files must not be indexed: {matches:?}"
1704        );
1705
1706        let context =
1707            retrieve_context(&root, "localization", "Rust infrastructure", 1200).expect("context");
1708        assert!(
1709            context
1710                .capsules
1711                .iter()
1712                .any(|capsule| capsule.expansion_handle.starts_with("memory:")),
1713            "expected memory capsule in context: {:?}",
1714            context.capsules
1715        );
1716
1717        rebuild_projection(&root).expect("rebuild projection");
1718        let matches = search_files(&root, "projection rebuild", 5).expect("search after rebuild");
1719        assert!(
1720            matches
1721                .iter()
1722                .any(|capsule| capsule.expansion_handle == "file:src/lib.rs"),
1723            "repo index should survive event-only rebuild: {matches:?}"
1724        );
1725
1726        fs::remove_dir_all(root).expect("remove temp project");
1727    }
1728
1729    #[test]
1730    fn run_finished_increments_usefulness_for_injected_memories() {
1731        with_user_brain_disabled(|| {
1732            // MP-4a outcome attribution + v0.5.1 citation split:
1733            // a memory that is BOTH injected (in context.injected) AND
1734            // cited (via memory.cited from the cite_memory tool) earns
1735            // the strong +1.0 usefulness delta on run.finished.
1736            //
1737            // Per-run counting: the same memory injected into two
1738            // stages of one run still counts once.
1739            let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1740            fs::create_dir_all(&root).expect("create temp project");
1741            init_project(&root, false).expect("init project");
1742            let memory_id = add_memory(
1743                &root,
1744                MemoryScope::GlobalUser,
1745                MemoryKind::Preference,
1746                "Prefer ripgrep over grep.",
1747            )
1748            .expect("add memory");
1749
1750            {
1751                let (paths, _config, conn) = load_project(&root).expect("load");
1752                let run_id = RunId::new();
1753                let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
1754                let evs: Vec<Event> = vec![
1755                    Event::new(
1756                        run_id,
1757                        "run.started",
1758                        serde_json::json!({"project_id": "test", "task": "x"}),
1759                    ),
1760                    Event::new(
1761                        run_id,
1762                        "context.injected",
1763                        serde_json::json!({
1764                            "stage": "localization",
1765                            "capsule_handles": [format!("memory:{memory_id}")],
1766                            "memory_ids": [memory_id.clone()],
1767                            "prior_run_ids": [],
1768                            "file_paths": [],
1769                        }),
1770                    ),
1771                    Event::new(
1772                        run_id,
1773                        "context.injected",
1774                        serde_json::json!({
1775                            "stage": "patch_plan",
1776                            "capsule_handles": [format!("memory:{memory_id}")],
1777                            "memory_ids": [memory_id.clone()],
1778                            "prior_run_ids": [],
1779                            "file_paths": [],
1780                        }),
1781                    ),
1782                    // v0.5.1: model explicitly cited the memory in
1783                    // turn 3 — earns the strong +1.0 delta.
1784                    Event::new(
1785                        run_id,
1786                        "memory.cited",
1787                        serde_json::json!({
1788                            "memory_id": memory_id,
1789                            "turn": 3,
1790                            "rationale": "using rg from memory",
1791                        }),
1792                    ),
1793                    Event::new(
1794                        run_id,
1795                        "run.finished",
1796                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
1797                    ),
1798                ];
1799                for ev in &evs {
1800                    writer.append(ev, true).expect("append");
1801                }
1802                projector::apply_events(&conn, &evs).expect("project");
1803            }
1804
1805            let memories = list_memories(&root).expect("list memories");
1806            let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
1807            assert_eq!(m.use_count, 1, "per-run counting: 2 stages count once");
1808            assert!(
1809                (m.usefulness_score - 1.0).abs() < f32::EPSILON,
1810                "expected strong-signal usefulness_score = 1.0, got {}",
1811                m.usefulness_score
1812            );
1813
1814            fs::remove_dir_all(root).expect("remove temp project");
1815        });
1816    }
1817
1818    /// v0.5.1: silent-passenger path. A memory that was retrieved
1819    /// (in context.injected) but the model never cited gets the
1820    /// weak +0.1 delta on run.finished, not the full +1.0.
1821    /// Encourages the model to actually call `cite_memory`.
1822    #[test]
1823    fn run_finished_gives_weak_signal_to_silent_passenger_memories() {
1824        with_user_brain_disabled(|| {
1825            let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1826            fs::create_dir_all(&root).expect("create temp project");
1827            init_project(&root, false).expect("init project");
1828            let memory_id = add_memory(
1829                &root,
1830                MemoryScope::GlobalUser,
1831                MemoryKind::Preference,
1832                "Silent passenger memory.",
1833            )
1834            .expect("add memory");
1835
1836            {
1837                let (paths, _config, conn) = load_project(&root).expect("load");
1838                let run_id = RunId::new();
1839                let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
1840                let evs: Vec<Event> = vec![
1841                    Event::new(
1842                        run_id,
1843                        "run.started",
1844                        serde_json::json!({"project_id": "test", "task": "x"}),
1845                    ),
1846                    Event::new(
1847                        run_id,
1848                        "context.injected",
1849                        serde_json::json!({
1850                            "stage": "localization",
1851                            "memory_ids": [memory_id.clone()],
1852                            "prior_run_ids": [],
1853                            "file_paths": [],
1854                        }),
1855                    ),
1856                    // NO memory.cited event for this memory.
1857                    Event::new(
1858                        run_id,
1859                        "run.finished",
1860                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
1861                    ),
1862                ];
1863                for ev in &evs {
1864                    writer.append(ev, true).expect("append");
1865                }
1866                projector::apply_events(&conn, &evs).expect("project");
1867            }
1868
1869            let memories = list_memories(&root).expect("list memories");
1870            let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
1871            assert_eq!(m.use_count, 1);
1872            assert!(
1873                (m.usefulness_score - 0.1).abs() < 1e-5,
1874                "silent passenger should get +0.1, got {}",
1875                m.usefulness_score
1876            );
1877        });
1878    }
1879
1880    /// v0.5.1 end-to-end: `blame_run` walks memory_citations +
1881    /// context.injected + terminal events and surfaces per-memory
1882    /// attribution. Cited memories appear under `cited`, retrieved-
1883    /// but-uncited under `silent_passengers`, and the outcome
1884    /// reflects the run's terminal event.
1885    #[test]
1886    fn blame_run_separates_cited_from_silent_passengers() {
1887        with_user_brain_disabled(|| {
1888            let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1889            fs::create_dir_all(&root).expect("create temp project");
1890            init_project(&root, false).expect("init project");
1891            let cited_id = add_memory(
1892                &root,
1893                MemoryScope::Repo,
1894                MemoryKind::Preference,
1895                "prefer ripgrep over grep",
1896            )
1897            .expect("add cited");
1898            let silent_id = add_memory(
1899                &root,
1900                MemoryScope::Repo,
1901                MemoryKind::Convention,
1902                "use cargo nextest for tests",
1903            )
1904            .expect("add silent");
1905
1906            let run_id = RunId::new();
1907            {
1908                let (paths, _config, conn) = load_project(&root).expect("load");
1909                let (mut writer, _run_paths) =
1910                    TraceWriter::create(&paths, run_id).expect("trace");
1911                let evs: Vec<Event> = vec![
1912                    Event::new(
1913                        run_id,
1914                        "run.started",
1915                        serde_json::json!({"project_id": "test", "task": "x"}),
1916                    ),
1917                    Event::new(
1918                        run_id,
1919                        "context.injected",
1920                        serde_json::json!({
1921                            "stage": "localization",
1922                            "memory_ids": [cited_id.clone(), silent_id.clone()],
1923                            "prior_run_ids": [],
1924                            "file_paths": [],
1925                        }),
1926                    ),
1927                    Event::new(
1928                        run_id,
1929                        "memory.cited",
1930                        serde_json::json!({
1931                            "memory_id": cited_id,
1932                            "turn": 4,
1933                            "rationale": "used the rg pattern",
1934                        }),
1935                    ),
1936                    Event::new(
1937                        run_id,
1938                        "run.finished",
1939                        serde_json::json!({"status": "success", "total_cost_usd": 0.1}),
1940                    ),
1941                ];
1942                for ev in &evs {
1943                    writer.append(ev, true).expect("append");
1944                }
1945                projector::apply_events(&conn, &evs).expect("project");
1946            }
1947
1948            let report = blame_run(&root, &run_id.to_string()).expect("blame");
1949            assert_eq!(report.outcome, "success");
1950            assert!(report.failure_category.is_none());
1951            assert_eq!(report.cited.len(), 1, "exactly one cited memory");
1952            let cited = &report.cited[0];
1953            assert_eq!(cited.memory_id, cited_id);
1954            assert_eq!(cited.turn, 4);
1955            assert_eq!(cited.rationale.as_deref(), Some("used the rg pattern"));
1956            assert!(cited.text_preview.contains("ripgrep"));
1957
1958            assert_eq!(report.silent_passengers.len(), 1);
1959            let silent = &report.silent_passengers[0];
1960            assert_eq!(silent.memory_id, silent_id);
1961            assert!(silent.text_preview.contains("nextest"));
1962
1963            fs::remove_dir_all(root).expect("cleanup");
1964        });
1965    }
1966
1967    #[test]
1968    fn run_failed_decrements_usefulness_unless_gate() {
1969        // run.failed with category != "Gate" decrements; category == "Gate"
1970        // is a graceful early-exit (e.g. the plan-create existence guard)
1971        // and must not blame memories that happened to be in context.
1972        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
1973        fs::create_dir_all(&root).expect("create temp project");
1974        init_project(&root, false).expect("init project");
1975        let memory_id = add_memory(
1976            &root,
1977            MemoryScope::Repo,
1978            MemoryKind::Convention,
1979            "Use find_* for fallible lookups.",
1980        )
1981        .expect("add memory");
1982
1983        {
1984            let (paths, _config, conn) = load_project(&root).expect("load");
1985
1986            // First run: gate-failure -> no update at all.
1987            let gate_run = RunId::new();
1988            let (mut writer, _) = TraceWriter::create(&paths, gate_run).expect("trace");
1989            let gate_events: Vec<Event> = vec![
1990                Event::new(
1991                    gate_run,
1992                    "run.started",
1993                    serde_json::json!({"project_id": "test", "task": "g"}),
1994                ),
1995                Event::new(
1996                    gate_run,
1997                    "context.injected",
1998                    serde_json::json!({
1999                        "stage": "patch_plan",
2000                        "capsule_handles": [format!("memory:{memory_id}")],
2001                        "memory_ids": [memory_id.clone()],
2002                        "prior_run_ids": [],
2003                        "file_paths": [],
2004                    }),
2005                ),
2006                Event::new(
2007                    gate_run,
2008                    "run.failed",
2009                    serde_json::json!({
2010                        "category": "Gate",
2011                        "failed_stage": "patch_plan",
2012                        "message": "files_to_create_already_exist",
2013                    }),
2014                ),
2015            ];
2016            for ev in &gate_events {
2017                writer.append(ev, true).expect("append");
2018            }
2019            projector::apply_events(&conn, &gate_events).expect("project gate-fail");
2020
2021            // Second run: real implementation failure + the memory
2022            // was cited via memory.cited -> -1.0 strong signal.
2023            let impl_run = RunId::new();
2024            let (mut writer2, _) = TraceWriter::create(&paths, impl_run).expect("trace");
2025            let impl_events: Vec<Event> = vec![
2026                Event::new(
2027                    impl_run,
2028                    "run.started",
2029                    serde_json::json!({"project_id": "test", "task": "i"}),
2030                ),
2031                Event::new(
2032                    impl_run,
2033                    "context.injected",
2034                    serde_json::json!({
2035                        "stage": "patch_plan",
2036                        "capsule_handles": [format!("memory:{memory_id}")],
2037                        "memory_ids": [memory_id.clone()],
2038                        "prior_run_ids": [],
2039                        "file_paths": [],
2040                    }),
2041                ),
2042                // v0.5.1: cite the memory so this run earns the
2043                // strong -1.0 penalty (the brain pushed wrong).
2044                Event::new(
2045                    impl_run,
2046                    "memory.cited",
2047                    serde_json::json!({
2048                        "memory_id": memory_id,
2049                        "turn": 2,
2050                        "rationale": "trusted the memory's pattern",
2051                    }),
2052                ),
2053                Event::new(
2054                    impl_run,
2055                    "run.failed",
2056                    serde_json::json!({
2057                        "category": "Implementation",
2058                        "failed_stage": "implementation",
2059                        "message": "test broke",
2060                    }),
2061                ),
2062            ];
2063            for ev in &impl_events {
2064                writer2.append(ev, true).expect("append");
2065            }
2066            projector::apply_events(&conn, &impl_events).expect("project impl-fail");
2067        }
2068
2069        let memories = list_memories(&root).expect("list memories");
2070        let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2071        assert_eq!(m.use_count, 1, "only the non-Gate failure counts as a use");
2072        assert!(
2073            (m.usefulness_score - (-1.0)).abs() < f32::EPSILON,
2074            "expected usefulness_score = -1.0, got {}",
2075            m.usefulness_score
2076        );
2077
2078        fs::remove_dir_all(root).expect("remove temp project");
2079    }
2080
2081    #[test]
2082    fn run_aborted_does_not_update_usefulness() {
2083        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2084        fs::create_dir_all(&root).expect("create temp project");
2085        init_project(&root, false).expect("init project");
2086        let memory_id = add_memory(
2087            &root,
2088            MemoryScope::Repo,
2089            MemoryKind::Convention,
2090            "Module re-exports live in lib.rs.",
2091        )
2092        .expect("add memory");
2093
2094        {
2095            let (paths, _config, conn) = load_project(&root).expect("load");
2096            let run_id = RunId::new();
2097            let (mut writer, _) = TraceWriter::create(&paths, run_id).expect("trace");
2098            let evs: Vec<Event> = vec![
2099                Event::new(
2100                    run_id,
2101                    "run.started",
2102                    serde_json::json!({"project_id": "test", "task": "a"}),
2103                ),
2104                Event::new(
2105                    run_id,
2106                    "context.injected",
2107                    serde_json::json!({
2108                        "stage": "patch_plan",
2109                        "capsule_handles": [format!("memory:{memory_id}")],
2110                        "memory_ids": [memory_id.clone()],
2111                        "prior_run_ids": [],
2112                        "file_paths": [],
2113                    }),
2114                ),
2115                Event::new(
2116                    run_id,
2117                    "run.aborted",
2118                    serde_json::json!({"reason": "user_abort"}),
2119                ),
2120            ];
2121            for ev in &evs {
2122                writer.append(ev, true).expect("append");
2123            }
2124            projector::apply_events(&conn, &evs).expect("project");
2125        }
2126
2127        let memories = list_memories(&root).expect("list memories");
2128        let m = memories.iter().find(|m| m.memory_id == memory_id).unwrap();
2129        assert_eq!(m.use_count, 0, "aborted runs must not update use_count");
2130        assert!(
2131            m.usefulness_score.abs() < f32::EPSILON,
2132            "expected usefulness_score = 0.0, got {}",
2133            m.usefulness_score
2134        );
2135
2136        fs::remove_dir_all(root).expect("remove temp project");
2137    }
2138
2139    #[test]
2140    fn list_proposals_filters_and_reject_records_reason() {
2141        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2142        fs::create_dir_all(&root).expect("create temp project");
2143        init_project(&root, false).expect("init project");
2144
2145        // Inject three proposals straight via memory.proposed events.
2146        let proposals = [
2147            (
2148                "p1",
2149                "global_user",
2150                "preference",
2151                0.9_f32,
2152                "Prefer rg over grep",
2153            ),
2154            (
2155                "p2",
2156                "repo",
2157                "convention",
2158                0.8,
2159                "Use find_* for fallible lookups",
2160            ),
2161            (
2162                "p3",
2163                "repo",
2164                "convention",
2165                0.4,
2166                "Use let-else where possible",
2167            ),
2168        ];
2169        {
2170            let (paths, _config, conn) = load_project(&root).expect("load");
2171            let run_id = RunId::new();
2172            let (mut writer, _run_paths) = TraceWriter::create(&paths, run_id).expect("trace");
2173            for (proposal_id, scope, kind, conf, text) in &proposals {
2174                let event = Event::new(
2175                    run_id,
2176                    "memory.proposed",
2177                    serde_json::json!({
2178                        "proposal_id": proposal_id,
2179                        "scope": scope,
2180                        "kind": kind,
2181                        "text": text,
2182                        "rationale": "test rationale",
2183                        "proposed_confidence": conf,
2184                        "source_event_ids": [],
2185                    }),
2186                );
2187                writer.append(&event, true).expect("append proposal");
2188                projector::apply_events(&conn, &[event]).expect("project");
2189            }
2190        }
2191
2192        // Filter by scope.
2193        let global = list_proposals(
2194            &root,
2195            ProposalFilter {
2196                scope: Some("global_user".into()),
2197                status: Some("pending".into()),
2198                ..ProposalFilter::default()
2199            },
2200        )
2201        .expect("list proposals");
2202        assert_eq!(global.len(), 1);
2203        assert_eq!(global[0].proposal_id, "p1");
2204
2205        // Filter by min_confidence.
2206        let strong = list_proposals(
2207            &root,
2208            ProposalFilter {
2209                min_confidence: Some(0.7),
2210                status: Some("pending".into()),
2211                ..ProposalFilter::default()
2212            },
2213        )
2214        .expect("list strong");
2215        assert_eq!(strong.len(), 2);
2216        for row in &strong {
2217            assert!(row.proposed_confidence >= 0.7);
2218        }
2219
2220        // Reject one with a reason and confirm it persists on the projected row.
2221        reject_proposal(&root, "p3", Some("not specific to the user")).expect("reject with reason");
2222        let rejected = list_proposals(
2223            &root,
2224            ProposalFilter {
2225                status: Some("rejected".into()),
2226                ..ProposalFilter::default()
2227            },
2228        )
2229        .expect("list rejected");
2230        assert_eq!(rejected.len(), 1);
2231        assert_eq!(rejected[0].proposal_id, "p3");
2232        assert_eq!(
2233            rejected[0].decided_reason.as_deref(),
2234            Some("not specific to the user")
2235        );
2236
2237        // Accept with a confidence override and confirm the resulting memory
2238        // carries the overridden value.
2239        let memory_id = accept_proposal(
2240            &root,
2241            "p1",
2242            AcceptOverrides {
2243                scope: None,
2244                confidence: Some(0.55),
2245            },
2246        )
2247        .expect("accept");
2248        let memories = list_memories(&root).expect("list memories");
2249        let promoted = memories
2250            .into_iter()
2251            .find(|m| m.memory_id == memory_id)
2252            .expect("promoted memory present");
2253        assert!((promoted.confidence - 0.55).abs() < f32::EPSILON);
2254
2255        fs::remove_dir_all(root).expect("remove temp project");
2256    }
2257
2258    /// MP-4d: invalidate_memory emits a `memory.invalidated` event and
2259    /// projects it. The memory row keeps everything but gains
2260    /// `invalidated_at`/`invalidated_reason`, and the row survives a
2261    /// projection rebuild (event is canonical).
2262    #[test]
2263    fn invalidate_memory_persists_invalidated_metadata_and_survives_rebuild() {
2264        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2265        fs::create_dir_all(&root).expect("create temp project");
2266        init_project(&root, false).expect("init project");
2267
2268        let memory_id = add_memory(
2269            &root,
2270            MemoryScope::Repo,
2271            MemoryKind::Convention,
2272            "Use find_* for fallible lookups.",
2273        )
2274        .expect("add memory");
2275
2276        invalidate_memory(&root, &memory_id, Some("hurt 4 runs in a row"))
2277            .expect("invalidate memory");
2278
2279        // Direct DB peek so we can read the new columns even before they are
2280        // surfaced via MemoryRow.
2281        {
2282            let (_paths, _config, conn) = load_project(&root).expect("load");
2283            let (invalidated_at, invalidated_reason): (Option<String>, Option<String>) = conn
2284                .query_row(
2285                    "SELECT invalidated_at, invalidated_reason FROM memories WHERE memory_id = ?1",
2286                    params![memory_id],
2287                    |row| Ok((row.get(0)?, row.get(1)?)),
2288                )
2289                .expect("query invalidated metadata");
2290            assert!(invalidated_at.is_some(), "invalidated_at must be set");
2291            assert_eq!(invalidated_reason.as_deref(), Some("hurt 4 runs in a row"));
2292        }
2293
2294        // Rebuild from trace and confirm invalidation survives.
2295        rebuild_projection(&root).expect("rebuild projection");
2296        {
2297            let (_paths, _config, conn) = load_project(&root).expect("load");
2298            let invalidated_at: Option<String> = conn
2299                .query_row(
2300                    "SELECT invalidated_at FROM memories WHERE memory_id = ?1",
2301                    params![memory_id],
2302                    |row| row.get(0),
2303                )
2304                .expect("query after rebuild");
2305            assert!(
2306                invalidated_at.is_some(),
2307                "invalidated_at must survive event replay"
2308            );
2309        }
2310
2311        fs::remove_dir_all(root).expect("remove temp project");
2312    }
2313
2314    /// MP-4b broker integration: an invalidated memory must not appear in
2315    /// the retrieved context bundle, even though the row still exists in
2316    /// brain.db for replay/audit.
2317    #[test]
2318    fn invalidated_memory_is_excluded_from_broker_retrieval() {
2319        with_user_brain_disabled(|| {
2320            let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2321            fs::create_dir_all(&root).expect("create temp project");
2322            init_project(&root, false).expect("init project");
2323
2324            let memory_id = add_memory(
2325                &root,
2326                MemoryScope::GlobalUser,
2327                MemoryKind::Preference,
2328                "Prefer ripgrep over grep for repo search.",
2329            )
2330            .expect("add memory");
2331
2332            // Sanity: broker surfaces it pre-invalidation.
2333            let pre = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
2334                .expect("pre context");
2335            assert!(
2336                pre.capsules
2337                    .iter()
2338                    .any(|c| c.expansion_handle == format!("memory:{memory_id}")),
2339                "memory must appear before invalidation: {:?}",
2340                pre.capsules
2341            );
2342
2343            invalidate_memory(&root, &memory_id, Some("no longer accurate")).expect("invalidate");
2344
2345            let post = retrieve_context(&root, "localization", "ripgrep grep search", 1200)
2346                .expect("post context");
2347            assert!(
2348                post.capsules
2349                    .iter()
2350                    .all(|c| c.expansion_handle != format!("memory:{memory_id}")),
2351                "invalidated memory must not be retrieved: {:?}",
2352                post.capsules
2353            );
2354
2355            // The row itself still exists in brain.db.
2356            let memories = list_memories(&root).expect("list");
2357            assert!(memories.iter().any(|m| m.memory_id == memory_id));
2358
2359            fs::remove_dir_all(root).expect("remove temp project");
2360        });
2361    }
2362
2363    /// MP-6: `list_memories_top` returns invalidated_at IS NULL memories
2364    /// sorted by ratio descending, filtered by `min_uses`. Memories with
2365    /// use_count below the threshold are dropped entirely so the listing
2366    /// only shows entries the broker bias actually applies to.
2367    #[test]
2368    fn list_memories_top_sorts_by_usefulness_ratio_and_drops_small_samples() {
2369        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2370        fs::create_dir_all(&root).expect("create temp project");
2371        init_project(&root, false).expect("init project");
2372
2373        let m_great =
2374            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "GREAT").expect("great");
2375        let m_meh =
2376            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "meh").expect("meh");
2377        let m_bad =
2378            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "BAD").expect("bad");
2379        let _m_fresh =
2380            add_memory(&root, MemoryScope::Repo, MemoryKind::Convention, "fresh").expect("fresh");
2381
2382        // Directly set usefulness data; the event-sourcing path is already
2383        // tested by `run_finished_increments_usefulness_for_injected_memories`.
2384        {
2385            let (_paths, _config, conn) = load_project(&root).expect("load");
2386            conn.execute(
2387                "UPDATE memories SET use_count = 5, usefulness_score = 4.0 WHERE memory_id = ?1",
2388                params![m_great],
2389            )
2390            .expect("set great");
2391            conn.execute(
2392                "UPDATE memories SET use_count = 5, usefulness_score = 0.0 WHERE memory_id = ?1",
2393                params![m_meh],
2394            )
2395            .expect("set meh");
2396            conn.execute(
2397                "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
2398                params![m_bad],
2399            )
2400            .expect("set bad");
2401            // m_fresh stays at use_count=0; should be excluded.
2402        }
2403
2404        let top = list_memories_top(
2405            &root,
2406            TopOptions {
2407                scope: None,
2408                min_uses: 3,
2409                limit: 10,
2410            },
2411        )
2412        .expect("top");
2413        assert_eq!(top.len(), 3, "fresh memory below min_uses must be excluded");
2414        assert_eq!(top[0].memory_id, m_great);
2415        assert_eq!(top[1].memory_id, m_meh);
2416        assert_eq!(top[2].memory_id, m_bad);
2417
2418        // Now invalidate the GREAT memory and confirm it disappears.
2419        invalidate_memory(&root, &m_great, Some("test")).expect("invalidate");
2420        let top_after = list_memories_top(
2421            &root,
2422            TopOptions {
2423                scope: None,
2424                min_uses: 3,
2425                limit: 10,
2426            },
2427        )
2428        .expect("top after");
2429        assert_eq!(top_after.len(), 2);
2430        assert!(top_after.iter().all(|m| m.memory_id != m_great));
2431
2432        fs::remove_dir_all(root).expect("remove temp project");
2433    }
2434
2435    /// MP-6: `prune_low_usefulness` lists candidates without writing when
2436    /// `apply = false`, and invalidates each match via the canonical
2437    /// `memory.invalidated` event path when `apply = true`. The prune
2438    /// reason includes the ratio + use_count so audit trail explains
2439    /// why the memory left.
2440    #[test]
2441    fn prune_low_usefulness_dry_run_then_apply() {
2442        with_user_brain_disabled(|| {
2443            prune_low_usefulness_dry_run_then_apply_body();
2444        });
2445    }
2446
2447    fn prune_low_usefulness_dry_run_then_apply_body() {
2448        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2449        fs::create_dir_all(&root).expect("create temp project");
2450        init_project(&root, false).expect("init project");
2451
2452        let m_keep = add_memory(
2453            &root,
2454            MemoryScope::Repo,
2455            MemoryKind::Convention,
2456            "keep me, I help",
2457        )
2458        .expect("keep");
2459        let m_drop_1 = add_memory(
2460            &root,
2461            MemoryScope::Repo,
2462            MemoryKind::Convention,
2463            "drop me, I hurt",
2464        )
2465        .expect("drop1");
2466        let m_drop_2 = add_memory(
2467            &root,
2468            MemoryScope::Repo,
2469            MemoryKind::Convention,
2470            "drop me too",
2471        )
2472        .expect("drop2");
2473        let m_small_sample = add_memory(
2474            &root,
2475            MemoryScope::Repo,
2476            MemoryKind::Convention,
2477            "small sample shouldn't be pruned even if score is bad",
2478        )
2479        .expect("small");
2480
2481        {
2482            let (_paths, _config, conn) = load_project(&root).expect("load");
2483            // keep: ratio = +0.6 (above threshold)
2484            conn.execute(
2485                "UPDATE memories SET use_count = 5, usefulness_score = 3.0 WHERE memory_id = ?1",
2486                params![m_keep],
2487            )
2488            .expect("set keep");
2489            // drop_1: ratio = -0.6 (well below -0.2)
2490            conn.execute(
2491                "UPDATE memories SET use_count = 5, usefulness_score = -3.0 WHERE memory_id = ?1",
2492                params![m_drop_1],
2493            )
2494            .expect("set drop1");
2495            // drop_2: ratio = -0.4
2496            conn.execute(
2497                "UPDATE memories SET use_count = 5, usefulness_score = -2.0 WHERE memory_id = ?1",
2498                params![m_drop_2],
2499            )
2500            .expect("set drop2");
2501            // small_sample: ratio = -1.0 but only 2 uses, must NOT be pruned
2502            conn.execute(
2503                "UPDATE memories SET use_count = 2, usefulness_score = -2.0 WHERE memory_id = ?1",
2504                params![m_small_sample],
2505            )
2506            .expect("set small");
2507        }
2508
2509        // Dry-run: lists candidates but does not invalidate.
2510        let dry = prune_low_usefulness(
2511            &root,
2512            PruneOptions {
2513                scope: None,
2514                min_uses: 3,
2515                max_ratio: -0.2,
2516                apply: false,
2517            },
2518        )
2519        .expect("dry-run");
2520        assert_eq!(dry.candidates.len(), 2);
2521        assert_eq!(dry.invalidated, 0);
2522        let ids: Vec<&str> = dry
2523            .candidates
2524            .iter()
2525            .map(|c| c.memory_id.as_str())
2526            .collect();
2527        assert!(ids.contains(&m_drop_1.as_str()));
2528        assert!(ids.contains(&m_drop_2.as_str()));
2529        // Confirm small_sample stayed out of the candidate list.
2530        assert!(!ids.contains(&m_small_sample.as_str()));
2531
2532        // Pre-apply state: all four memories still active.
2533        let pre = list_memories(&root).expect("pre");
2534        assert_eq!(pre.len(), 4);
2535
2536        // Apply: both bad memories invalidated, keep + small_sample untouched.
2537        let applied = prune_low_usefulness(
2538            &root,
2539            PruneOptions {
2540                scope: None,
2541                min_uses: 3,
2542                max_ratio: -0.2,
2543                apply: true,
2544            },
2545        )
2546        .expect("apply");
2547        assert_eq!(applied.candidates.len(), 2);
2548        assert_eq!(applied.invalidated, 2);
2549        assert_eq!(applied.failed, 0);
2550
2551        // Post-apply: list_memories_top with min_uses=3 should now only
2552        // surface the keep memory (drops are invalidated_at IS NOT NULL,
2553        // small_sample is filtered by min_uses).
2554        let top = list_memories_top(
2555            &root,
2556            TopOptions {
2557                scope: None,
2558                min_uses: 3,
2559                limit: 10,
2560            },
2561        )
2562        .expect("top after prune");
2563        assert_eq!(top.len(), 1);
2564        assert_eq!(top[0].memory_id, m_keep);
2565
2566        // Confirm the canonical event trail: each pruned memory has a
2567        // non-null invalidated_at and the reason mentions "pruned_by_usefulness".
2568        // Scope the connection so it's dropped before fs::remove_dir_all
2569        // on Windows, where SQLite holds an exclusive lock on the journal.
2570        {
2571            let (_paths, _config, conn) = load_project(&root).expect("load");
2572            let reason: String = conn
2573                .query_row(
2574                    "SELECT invalidated_reason FROM memories WHERE memory_id = ?1",
2575                    params![m_drop_1],
2576                    |row| row.get(0),
2577                )
2578                .expect("invalidated reason");
2579            assert!(
2580                reason.starts_with("pruned_by_usefulness"),
2581                "unexpected reason: {reason}"
2582            );
2583        }
2584
2585        fs::remove_dir_all(root).expect("remove temp project");
2586    }
2587
2588    /// MP-5a: the brain primitives behind `kimetsu brain memory review`.
2589    /// Workflow: inject several proposals across two runs, filter by run +
2590    /// confidence to pick the keepers, batch-accept those, then
2591    /// batch-reject the remainder. The final state must show exactly the
2592    /// accepted proposals as memories and exactly the rejected proposals
2593    /// carrying a non-empty decided_reason.
2594    #[test]
2595    fn batch_review_accepts_filtered_subset_and_rejects_remainder() {
2596        with_user_brain_disabled(|| {
2597            batch_review_accepts_filtered_subset_and_rejects_remainder_body();
2598        });
2599    }
2600
2601    fn batch_review_accepts_filtered_subset_and_rejects_remainder_body() {
2602        let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2603        fs::create_dir_all(&root).expect("create temp project");
2604        init_project(&root, false).expect("init project");
2605
2606        let run_a = RunId::new();
2607        let run_b = RunId::new();
2608
2609        // Two proposals from run_a (one strong, one weak) plus two more
2610        // from run_b. The "review" flow will accept run_a's strong one,
2611        // reject everything else.
2612        let proposals: [(&str, RunId, &str, &str, f32, &str); 4] = [
2613            (
2614                "p_a_strong",
2615                run_a,
2616                "global_user",
2617                "preference",
2618                0.92,
2619                "Prefer rg over grep",
2620            ),
2621            (
2622                "p_a_weak",
2623                run_a,
2624                "repo",
2625                "convention",
2626                0.55,
2627                "Always use let-else",
2628            ),
2629            (
2630                "p_b1",
2631                run_b,
2632                "repo",
2633                "convention",
2634                0.70,
2635                "Use Result not panic",
2636            ),
2637            (
2638                "p_b2",
2639                run_b,
2640                "global_user",
2641                "preference",
2642                0.88,
2643                "Open links in new tab",
2644            ),
2645        ];
2646
2647        {
2648            let (paths, _config, conn) = load_project(&root).expect("load");
2649            for (proposal_id, run_id, scope, kind, conf, text) in &proposals {
2650                let (mut writer, _) = TraceWriter::create(&paths, *run_id).expect("trace");
2651                let event = Event::new(
2652                    *run_id,
2653                    "memory.proposed",
2654                    serde_json::json!({
2655                        "proposal_id": proposal_id,
2656                        "scope": scope,
2657                        "kind": kind,
2658                        "text": text,
2659                        "rationale": "fixture",
2660                        "proposed_confidence": conf,
2661                        "source_event_ids": [],
2662                    }),
2663                );
2664                writer.append(&event, true).expect("append");
2665                projector::apply_events(&conn, &[event]).expect("project");
2666            }
2667        }
2668
2669        // Step 1: --accept-all --from-run <run_a> --min-confidence 0.8
2670        // mirrors the CLI filter + accept loop.
2671        let to_accept = list_proposals(
2672            &root,
2673            ProposalFilter {
2674                from_run: Some(run_a.to_string()),
2675                min_confidence: Some(0.8),
2676                status: Some("pending".into()),
2677                limit: 100,
2678                ..ProposalFilter::default()
2679            },
2680        )
2681        .expect("list strong from run_a");
2682        assert_eq!(to_accept.len(), 1, "filter should keep only p_a_strong");
2683        assert_eq!(to_accept[0].proposal_id, "p_a_strong");
2684        let memory_id =
2685            accept_proposal(&root, &to_accept[0].proposal_id, AcceptOverrides::default())
2686                .expect("accept p_a_strong");
2687
2688        // Step 2: --reject-all --reason "batch_reject" over the remaining
2689        // pending proposals.
2690        let to_reject = list_proposals(
2691            &root,
2692            ProposalFilter {
2693                status: Some("pending".into()),
2694                limit: 100,
2695                ..ProposalFilter::default()
2696            },
2697        )
2698        .expect("list remaining pending");
2699        assert_eq!(to_reject.len(), 3, "three proposals should remain pending");
2700        for p in &to_reject {
2701            reject_proposal(&root, &p.proposal_id, Some("batch_reject")).expect("reject in batch");
2702        }
2703
2704        // Final state: exactly one memory; exactly three rejected proposals;
2705        // zero pending. Decision reason persisted on each rejected row.
2706        let memories = list_memories(&root).expect("list memories");
2707        assert_eq!(
2708            memories.len(),
2709            1,
2710            "only the accepted proposal becomes a memory"
2711        );
2712        assert_eq!(memories[0].memory_id, memory_id);
2713
2714        let pending = list_proposals(
2715            &root,
2716            ProposalFilter {
2717                status: Some("pending".into()),
2718                limit: 100,
2719                ..ProposalFilter::default()
2720            },
2721        )
2722        .expect("list pending");
2723        assert!(
2724            pending.is_empty(),
2725            "no proposals left pending after batch review"
2726        );
2727
2728        let rejected = list_proposals(
2729            &root,
2730            ProposalFilter {
2731                status: Some("rejected".into()),
2732                limit: 100,
2733                ..ProposalFilter::default()
2734            },
2735        )
2736        .expect("list rejected");
2737        assert_eq!(rejected.len(), 3);
2738        for row in &rejected {
2739            assert_eq!(row.decided_reason.as_deref(), Some("batch_reject"));
2740        }
2741
2742        fs::remove_dir_all(root).expect("remove temp project");
2743    }
2744
2745    /// v0.5.2: end-to-end regression for the lean (NoopEmbedder)
2746    /// build. `add_memory` must NOT write any rows to
2747    /// `memory_conflicts` when the embedder is a no-op, and the
2748    /// `list_conflicts` / `resolve_conflict` wrappers must return
2749    /// the empty/false answer rather than panicking on a missing
2750    /// table or a malformed query.
2751    ///
2752    /// Real semantic-conflict detection is exercised exhaustively
2753    /// in `crate::conflict::tests` with a StubEmbedder; this test
2754    /// guards the project-level plumbing only.
2755    #[test]
2756    fn add_memory_under_noop_embedder_writes_no_conflicts() {
2757        with_user_brain_disabled(|| {
2758            let root = std::env::temp_dir().join(format!("kimetsu-test-{}", Ulid::new()));
2759            fs::create_dir_all(&root).expect("create temp project");
2760            init_project(&root, false).expect("init project");
2761
2762            // Two near-duplicate memories. Under NoopEmbedder no
2763            // conflict is detected; the two rows simply both exist
2764            // (and don't collide via the dedup gate because their
2765            // normalized texts differ).
2766            let _m1 = add_memory(
2767                &root,
2768                MemoryScope::GlobalUser,
2769                MemoryKind::Preference,
2770                "Prefer thiserror for library error types.",
2771            )
2772            .expect("add m1");
2773            let _m2 = add_memory(
2774                &root,
2775                MemoryScope::GlobalUser,
2776                MemoryKind::Preference,
2777                "Prefer anyhow for library error types.",
2778            )
2779            .expect("add m2");
2780
2781            let open = list_conflicts(&root, 50).expect("list_conflicts");
2782            assert!(
2783                open.is_empty(),
2784                "noop embedder must not generate conflicts; got {} rows",
2785                open.len()
2786            );
2787
2788            // Resolving a non-existent id should return false, not error.
2789            let resolved = resolve_conflict(&root, "does-not-exist", "kept_both")
2790                .expect("resolve_conflict on unknown id");
2791            assert!(!resolved, "unknown conflict id should resolve to false");
2792
2793            // Invalid resolution strings should be rejected up front.
2794            let err = resolve_conflict(&root, "does-not-exist", "garbage")
2795                .expect_err("invalid resolution should error");
2796            assert!(format!("{err}").contains("invalid conflict resolution"));
2797
2798            fs::remove_dir_all(root).expect("remove temp project");
2799        });
2800    }
2801}