Skip to main content

lean_ctx_server/tools/registered/
ctx_knowledge.rs

1// SPDX-License-Identifier: Apache-2.0
2use rmcp::ErrorData;
3use rmcp::model::Tool;
4use serde_json::{Map, Value, json};
5
6use crate::server::tool_trait::{
7    McpTool, ToolContext, ToolOutput, get_f64, get_str, get_str_array,
8};
9use lean_ctx::tool_defs::tool_def;
10
11pub struct CtxKnowledgeTool;
12
13impl McpTool for CtxKnowledgeTool {
14    fn name(&self) -> &'static str {
15        "ctx_knowledge"
16    }
17
18    fn tool_def(&self) -> Tool {
19        tool_def(
20            "ctx_knowledge",
21            "Persistent memory across sessions — remember decisions, patterns, and facts for recall.\n\
22             WORKFLOW: save after completing significant tasks; recall at session start.\n\
23             action=remember value='Y' saves a fact (key optional — derived from value; content= is an accepted alias).\n\
24             action=recall query='X' retrieves it (bare recall lists recent facts). action=status shows all categories.\n\
25action=consolidate imports latest session if present, runs lifecycle, then frees 25% history/patterns capacity.\n\
26             action=gotcha trigger='X' resolution='Y' for known pitfalls.\n\
27             action=decisions [path=P|symbol=S] lists project decisions (.leanctx/decisions) in scope.\n\
28             mode=semantic|exact for recall. category groups related facts.",
29            json!({
30                "type": "object",
31                "properties": {
32                    "action": {
33                        "type": "string",
34                        "description": "remember|recall|search|pattern|gotcha|decisions|relate|relations|consolidate|restore|status|timeline|rooms|wakeup|remove|export|import (also: feedback, unrelate, relations_diagram, health, lifecycle_report, policy, embeddings_*; changes query=<lsn>|session, repin, coverage)"
35                    },
36                    "trigger": { "type": "string", "description": "gotcha trigger pattern" },
37                    "resolution": { "type": "string", "description": "gotcha resolution/fix" },
38                    "severity": { "type": "string", "description": "gotcha: critical|warning|info" },
39                    "category": { "type": "string", "description": "Fact category" },
40                    "key": { "type": "string" },
41                    "value": { "type": "string", "description": "remember/pattern: the text. wakeup (optional): the memory hash a full briefing ended with, only while that briefing is still in your context — answered \"unchanged\" only if exactly that briefing was released to a request of the same host presenter capability; in every other case the full briefing is returned" },
42                    "content": { "type": "string", "description": "Alias for value (remember)" },
43                    "sources": { "type": "array", "items": { "type": "string" }, "description": "remember: project files the fact is derived from (read now and bound to it)" },
44                    "anchors": { "type": "array", "items": { "type": "string" }, "description": "remember: code the fact is about, path or path::symbol (max 16); a later change there marks the fact to verify" },
45                    "type": { "type": "string", "description": "remember: fact|decision|gotcha|procedure|finding (Hive capture)" },
46                    "refs": { "type": "array", "items": { "type": "string" }, "description": "remember: references for Hive capture (max 10): file:<path>[#L<a>-<b>], sym:<path>#<symbol>, test:<path>::<name>, commit:<sha>, https://…" },
47                    "evidence": { "type": "string", "description": "remember: observed|stated|imported" },
48                    "steps": { "type": "array", "items": { "type": "string" }, "description": "remember type=procedure: steps (value = title)" },
49                    "commands": { "type": "array", "items": { "type": "object", "properties": { "shell": { "type": "string" }, "cmd": { "type": "string" }, "cwd_hint": { "type": "string" } }, "required": ["shell", "cmd"] }, "description": "remember type=procedure: commands as data (never executed)" },
50                    "validity": { "type": "string", "description": "remember: convention (project rule, shared with linked checkouts regardless of revision) | content (bound to its sources' revision). Default: convention for category convention/decision, content when sources bind" },
51                    "query": { "type": "string", "description": "Query for recall/search/relate/restore" },
52                    "mode": { "type": "string", "description": "auto|exact|semantic|hybrid" },
53                    "as_of": { "type": "string", "description": "YYYY-MM-DD date filter" },
54                    "pattern_type": { "type": "string" },
55                    "examples": { "type": "array", "items": { "type": "string" } },
56                    "confidence": { "type": "number", "description": "0.0-1.0" },
57                    "store": { "type": "string", "description": "restore: facts|history|patterns (default: all)" },
58                    "limit": { "type": "number", "description": "restore: max items to recover (default 50)" },
59                    "dry_run": { "type": "boolean", "description": "consolidate: preview imports/reclaim without writing" },
60                    "format": { "type": "string", "description": "export: json|okf (okf = portable Markdown bundle)" },
61                    "path": { "type": "string", "description": "export/import: bundle directory (OKF) or file path; decisions: file whose in-scope decisions to list" },
62                    "symbol": { "type": "string", "description": "decisions: symbol (name or path::name) whose decisions to list" },
63                    "merge": { "type": "string", "description": "import: replace|append|skip-existing (default skip-existing)" }
64                },
65                "allOf": [
66                    {
67                        "if": { "properties": { "action": { "const": "remember" } }, "required": ["action"] },
68                        "then": { "required": ["category"], "anyOf": [{ "required": ["value"] }, { "required": ["content"] }] }
69                    },
70                    {
71                        "if": { "properties": { "action": { "const": "pattern" } }, "required": ["action"] },
72                        "then": { "required": ["value"] }
73                    },
74                    {
75                        "if": { "properties": { "action": { "const": "search" } }, "required": ["action"] },
76                        "then": { "required": ["query"] }
77                    },
78                    {
79                        "if": { "properties": { "action": { "const": "gotcha" } }, "required": ["action"] },
80                        "then": { "required": ["trigger", "resolution"] }
81                    }
82                ],
83                "required": ["action"]
84            }),
85        )
86    }
87
88    fn handle(
89        &self,
90        args: &Map<String, Value>,
91        ctx: &ToolContext,
92    ) -> Result<ToolOutput, ErrorData> {
93        let action = get_str(args, "action")
94            .ok_or_else(|| ErrorData::invalid_params("action is required", None))?;
95        let category = get_str(args, "category");
96        let key = get_str(args, "key");
97        // `content` is the wording our own workflow docs use for remember;
98        // accept it as an alias so agents following those docs succeed (#658).
99        let value = get_str(args, "value").or_else(|| get_str(args, "content"));
100        let query = get_str(args, "query");
101        let mode = get_str(args, "mode");
102        let as_of = get_str(args, "as_of");
103        let pattern_type = get_str(args, "pattern_type");
104        let examples = get_str_array(args, "examples");
105        let confidence = get_f64(args, "confidence").map(|v| v as f32);
106        let validity = match get_str(args, "validity") {
107            None => None,
108            Some(raw) => Some(
109                lean_ctx::core::knowledge::FactValidity::parse(&raw).ok_or_else(|| {
110                    ErrorData::invalid_params("validity must be convention or content", None)
111                })?,
112            ),
113        };
114
115        let session_handle = ctx
116            .session
117            .as_ref()
118            .ok_or_else(|| ErrorData::internal_error("session not available", None))?;
119        let (session_id, project_root) = {
120            let read_result =
121                crate::server::bounded_lock::read(session_handle, "ctx_knowledge session read");
122            if let Some(session) = read_result {
123                let sid = session.id.clone();
124                let root = session
125                    .project_root
126                    .clone()
127                    .unwrap_or_else(|| ctx.project_root.clone());
128                (sid, root)
129            } else {
130                tracing::warn!("ctx_knowledge: session read-lock timeout, using fallback");
131                ("unknown".to_string(), ctx.project_root.clone())
132            }
133        };
134
135        // Project decisions (`.leanctx/decisions/*.toml`): read-only query.
136        if action == "decisions" {
137            let text = lean_ctx::core::decisions::query_text(
138                std::path::Path::new(&project_root),
139                get_str(args, "path").as_deref(),
140                get_str(args, "symbol").as_deref(),
141            );
142            return Ok(text_output(&action, text));
143        }
144
145        if action == "gotcha" {
146            let trigger = get_str(args, "trigger").unwrap_or_default();
147            let resolution = get_str(args, "resolution").unwrap_or_default();
148            let severity = get_str(args, "severity").unwrap_or_default();
149            let cat = category.as_deref().unwrap_or("convention");
150
151            if trigger.is_empty() || resolution.is_empty() {
152                return Ok(text_output(
153                    &action,
154                    "ERROR: trigger and resolution are required for gotcha action".to_string(),
155                ));
156            }
157
158            let mut store = lean_ctx::core::gotcha_tracker::GotchaStore::load(&project_root);
159            let msg = match store.report_gotcha(&trigger, &resolution, cat, &severity, &session_id)
160            {
161                Some(gotcha) => {
162                    let conf = (gotcha.confidence * 100.0) as u32;
163                    let label = gotcha.category.short_label();
164                    format!("Gotcha recorded: [{label}] {trigger} (confidence: {conf}%)")
165                }
166                None => {
167                    format!("Gotcha noted: {trigger} (evicted by higher-confidence entries)")
168                }
169            };
170            let _ = store.save(&project_root);
171            return Ok(text_output(&action, msg));
172        }
173
174        // Restore (#995 Phase 6): explicit cross-store undo from archive. Handled
175        // inline (like `gotcha`) so `store`/`limit` can be passed without widening
176        // the shared `handle()` signature.
177        if action == "restore" {
178            let store = match get_str(args, "store").as_deref() {
179                Some(s) if lean_ctx::core::memory_archive::MemoryStore::is_retired(s) => {
180                    return Ok(text_output(
181                        &action,
182                        lean_ctx::core::memory_archive::RETIRED_PROCEDURES_STORE_NOTICE.to_string(),
183                    ));
184                }
185                Some(s) => match lean_ctx::core::memory_archive::MemoryStore::parse(s) {
186                    Some(ms) => Some(ms),
187                    None => {
188                        return Ok(text_output(
189                            &action,
190                            format!("Unknown store: {s}. Use: facts, history, patterns"),
191                        ));
192                    }
193                },
194                None => None,
195            };
196            let limit = get_f64(args, "limit")
197                .map_or(crate::tools::ctx_knowledge::DEFAULT_RESTORE_LIMIT, |v| {
198                    v as usize
199                });
200            let opts =
201                crate::tools::ctx_knowledge::RestoreOptions::new(store, query.clone(), limit);
202            let text = match crate::tools::ctx_knowledge::run_restore(&project_root, &opts) {
203                Ok(report) => crate::tools::ctx_knowledge::format_restore_report(&report),
204                Err(e) => e,
205            };
206            return Ok(text_output(&action, text));
207        }
208
209        // Dry-run consolidate: preview imports + reclaim with no writes.
210        let dry_run = args
211            .get("dry_run")
212            .and_then(Value::as_bool)
213            .unwrap_or(false);
214        if action == "consolidate" && dry_run {
215            let text = match crate::tools::ctx_knowledge::consolidate_project_knowledge_with(
216                &project_root,
217                &lean_ctx::core::consolidation_engine::ConsolidateOptions::manual().into_dry_run(),
218            ) {
219                Ok(report) => crate::tools::ctx_knowledge::format_consolidation_report(&report),
220                Err(e) => e,
221            };
222            return Ok(text_output(&action, text));
223        }
224
225        // OKF export/import handled inline so `format`/`path`/`merge` stay off the
226        // shared handle() signature (same pattern as restore/gotcha above).
227        if action == "export" && get_str(args, "format").as_deref() == Some("okf") {
228            let out = match get_str(args, "path").or_else(|| get_str(args, "output")) {
229                Some(p) => match jailed_path("ctx_knowledge.export", &p, &project_root) {
230                    // Under a Workspace the authority confines it as well.
231                    Ok(p) => {
232                        match lean_ctx::core::workspace_authority::confine(&p, &project_root, true)
233                        {
234                            Ok(p) => Some(p),
235                            Err(error) => {
236                                return Ok(text_output(&action, format!("ERROR: {error}")));
237                            }
238                        }
239                    }
240                    Err(e) => return Ok(text_output(&action, e)),
241                },
242                None => None,
243            };
244            return Ok(text_output(
245                &action,
246                crate::tools::ctx_knowledge::handle_export_okf(&project_root, out.as_deref()),
247            ));
248        }
249        // Hive (#270): a write that moved the knowledge journal (remember,
250        // a conflict's fact update, forget, import …) is pushed to Hive
251        // from this process within about a second, off this call.
252        let journal_before = journal_head(&project_root);
253        if action == "import" {
254            let Some(path) = get_str(args, "path").or_else(|| query.clone()) else {
255                return Ok(text_output(
256                    &action,
257                    "ERROR: import requires `path` (a file or an OKF directory)".to_string(),
258                ));
259            };
260            let path =
261                match jailed_path("ctx_knowledge.import", &path, &project_root).and_then(|p| {
262                    // Under a Workspace the authority confines it as well.
263                    lean_ctx::core::workspace_authority::confine(&p, &project_root, false)
264                        .map_err(|error| format!("ERROR: {error}"))
265                }) {
266                    Ok(p) => p,
267                    Err(e) => return Ok(text_output(&action, e)),
268                };
269            let merge = get_str(args, "merge")
270                .and_then(|s| lean_ctx::core::knowledge::ImportMerge::parse(&s))
271                .unwrap_or(lean_ctx::core::knowledge::ImportMerge::SkipExisting);
272            let text = crate::tools::ctx_knowledge::handle_import(
273                &project_root,
274                &path,
275                merge,
276                &session_id,
277            );
278            push_if_written(&project_root, journal_before);
279            return Ok(text_output(&action, text));
280        }
281
282        // A typed failure (every failure of the declaring actions `recall`,
283        // `timeline`, `search`, and the refusals all actions share) is a tool
284        // error with the same text — never a reply that could be recorded as
285        // a delivery of the sources captured before it failed.
286        let outcome = crate::tools::ctx_knowledge::handle_outcome(
287            &project_root,
288            &action,
289            category.as_deref(),
290            key.as_deref(),
291            value.as_deref(),
292            query.as_deref(),
293            &session_id,
294            pattern_type.as_deref(),
295            examples,
296            confidence,
297            mode.as_deref(),
298            as_of.as_deref(),
299        );
300        let failed = outcome.is_err();
301        let (Ok(result) | Err(result)) = outcome;
302        let named = get_str_array(args, "sources").unwrap_or_default();
303        let result = if action == "remember" && !failed {
304            remembered_with(
305                result,
306                &project_root,
307                category.as_deref(),
308                key.as_deref(),
309                value.as_deref(),
310                &named,
311                validity,
312            )
313        } else {
314            result
315        };
316        // J2: anchors to the code the fact is about, all or none.
317        let anchors = get_str_array(args, "anchors").unwrap_or_default();
318        let result = match (
319            action == "remember" && !failed && !anchors.is_empty(),
320            category.as_deref(),
321            value.as_deref(),
322        ) {
323            (true, Some(category), Some(value)) => {
324                match crate::tools::ctx_knowledge::anchor_remembered(
325                    &project_root,
326                    category,
327                    key.as_deref(),
328                    value,
329                    &anchors,
330                ) {
331                    Ok(set) => format!("{result}\nanchors: {} recorded", set.len()),
332                    Err(reason) => {
333                        format!("{result}\nanchors: not recorded ({reason}); the fact has none")
334                    }
335                }
336            }
337            _ => result,
338        };
339        // Hive capture fields (v4 V1), all or none.
340        let result = match (
341            action == "remember" && !failed,
342            category.as_deref(),
343            value.as_deref(),
344        ) {
345            (true, Some(category), Some(value)) => match capture_fields(args) {
346                Ok(None) => result,
347                Ok(Some(fields)) => match crate::tools::ctx_knowledge::capture_remembered(
348                    &project_root,
349                    category,
350                    key.as_deref(),
351                    value,
352                    fields,
353                ) {
354                    Ok(()) => format!("{result}\ncapture: recorded"),
355                    Err(reason) => format!("{result}\ncapture: not recorded ({reason})"),
356                },
357                Err(reason) => format!("{result}\ncapture: not recorded ({reason})"),
358            },
359            _ => result,
360        };
361        // Hive: whether the fact goes to the team, or stays local and why
362        // (the capture gate's rules, checked now instead of at sync).
363        let result = match (action == "remember" && !failed, value.as_deref()) {
364            (true, Some(value)) => {
365                let has_ref = get_str_array(args, "refs").is_some_and(|r| !r.is_empty())
366                    || !anchors.is_empty();
367                match lean_ctx_hive::capture::sharing::note(&project_root, value, has_ref) {
368                    Some(line) => format!("{result}\nhive: {line}"),
369                    None => result,
370                }
371            }
372            _ => result,
373        };
374        push_if_written(&project_root, journal_before);
375
376        if failed {
377            return Err(crate::server::tool_trait::tool_execution_error(result));
378        }
379        Ok(text_output(&action, result))
380    }
381
382    /// Declared for `recall`, `timeline` and `search`. Their replies are
383    /// fixed text, the request's own arguments, and stored facts with their
384    /// identity and counters; every fact passes
385    /// `ctx_knowledge::fact_served`, which publishes the files of a fact
386    /// bound to sources and marks the capture incomplete for a fact without
387    /// a binding. A judgement line under a recalled fact marks it too.
388    /// `search` adds session entries only as the originless rule serves
389    /// them, which is never while protection is active.
390    ///
391    /// Not declared: `remember` (echoes the value and previews similar
392    /// facts), `status`, `wakeup` and `export` (rendered by the knowledge
393    /// store, which consults no binding), `relations*`, `restore`,
394    /// `consolidate*`, `gotcha`, `pattern`, the bridge and embeddings
395    /// actions (other stores or no fact value), and the rest (no stored
396    /// fact in the reply).
397    fn publishes_every_source(&self, args: Option<&Map<String, Value>>) -> bool {
398        matches!(
399            args.and_then(|args| get_str(args, "action")).as_deref(),
400            Some("recall" | "timeline" | "search")
401        )
402    }
403}
404
405/// The newest LSN of the knowledge journal of `project_root`.
406fn journal_head(project_root: &str) -> Option<u64> {
407    lean_ctx::core::knowledge::knowledge_dir_of(project_root)
408        .ok()
409        .map(|dir| lean_ctx::core::knowledge::journal::head_lsn(&dir))
410}
411
412/// Push the vault of `project_root` to Hive soon when the journal moved
413/// past `before` (the head before the action ran).
414fn push_if_written(project_root: &str, before: Option<u64>) {
415    if before.is_some() && journal_head(project_root) != before {
416        lean_ctx_hive::sync::on_write::push_soon(project_root);
417    }
418}
419
420/// The capture fields a `remember` names; `None` when it names none.
421fn capture_fields(
422    args: &Map<String, Value>,
423) -> Result<Option<lean_ctx::core::knowledge::FactCapture>, String> {
424    use lean_ctx::core::knowledge::{EvidenceLevel, FactCapture, NodeType, ProcedureCommand};
425    let node_type = get_str(args, "type")
426        .map(|t| NodeType::parse(&t).ok_or_else(|| format!("type {t:?}: not a node type")))
427        .transpose()?;
428    let evidence = get_str(args, "evidence")
429        .map(|e| EvidenceLevel::parse(&e).ok_or_else(|| format!("evidence {e:?}: not a level")))
430        .transpose()?;
431    let commands: Vec<ProcedureCommand> = match args.get("commands") {
432        Some(v) => serde_json::from_value(v.clone()).map_err(|e| format!("commands: {e}"))?,
433        None => Vec::new(),
434    };
435    let fields = FactCapture {
436        node_type,
437        steps: get_str_array(args, "steps").unwrap_or_default(),
438        commands,
439        refs: get_str_array(args, "refs").unwrap_or_default(),
440        evidence,
441        changed_at: None,
442    };
443    Ok((fields != FactCapture::default()).then_some(fields))
444}
445
446const SOURCES_BOUND: &str = "sources: bound to the fact";
447const SOURCES_NOT_BOUND: &str = "sources: not bound (a named source is not an admitted project file, or the fact was not stored as given); the fact has no source binding";
448const SOURCES_NOT_BOUND_CONVENTION: &str =
449    "sources: not bound (a convention is not bound to a revision)";
450
451/// The `remember` reply with what this call recorded beyond the fact: the
452/// source binding (`sources`) and the validity class (AP01 §4). An explicit
453/// `validity` wins; a fact that names sources is `content`. A
454/// convention is never bound to sources — it is shared regardless of
455/// revision, so a revision must not decide whether it is served.
456fn remembered_with(
457    reply: String,
458    project_root: &str,
459    category: Option<&str>,
460    key: Option<&str>,
461    value: Option<&str>,
462    named: &[String],
463    validity: Option<lean_ctx::core::knowledge::FactValidity>,
464) -> String {
465    use lean_ctx::core::knowledge::FactValidity;
466    let mut reply = reply;
467    if !named.is_empty() {
468        let line = if validity == Some(FactValidity::Convention) {
469            SOURCES_NOT_BOUND_CONVENTION
470        } else if bind_remembered(project_root, category, key, value, named) {
471            SOURCES_BOUND
472        } else {
473            SOURCES_NOT_BOUND
474        };
475        reply = format!("{reply}\n{line}");
476    }
477    // A fact that names files is about code at a revision, bound or not: an
478    // unbound one is then never served where its files cannot be checked.
479    let class = validity.or((!named.is_empty()).then_some(FactValidity::Content));
480    if let (Some(class), Some(category), Some(value)) = (class, category, value)
481        && crate::tools::ctx_knowledge::classify_remembered(
482            project_root,
483            category,
484            key,
485            value,
486            class,
487        )
488    {
489        reply = format!("{reply}\nvalidity: {}", class.as_str());
490    }
491    reply
492}
493
494/// Bind the fact a `remember` has just stored to the `sources` its writer
495/// named. Each is read now through the admitted reader; the fact is bound to
496/// all of them or to none (`archive::bindings`). Bound is only the fact that
497/// carries exactly the value of this call — never an older value under the
498/// same key, and never a value the store merged, rewrote or rejected.
499fn bind_remembered(
500    project_root: &str,
501    category: Option<&str>,
502    key: Option<&str>,
503    value: Option<&str>,
504    sources: &[String],
505) -> bool {
506    use lean_ctx::core::archive::bindings::{self, Item};
507    let (Some(category), Some(value)) = (category, value) else {
508        return false;
509    };
510    let Some(knowledge) = lean_ctx::core::knowledge::ProjectKnowledge::load(project_root) else {
511        return false;
512    };
513    let Some(fact) = knowledge.facts.iter().rev().find(|fact| {
514        fact.is_current()
515            && fact.category == category
516            && fact.value == value
517            && key.is_none_or(|key| fact.key == key)
518    }) else {
519        return false;
520    };
521    let Some(sources) = bindings::sources_at(project_root, sources, "ctx_knowledge") else {
522        return false;
523    };
524    let item = Item::KnowledgeFact {
525        project: project_root,
526        category: &fact.category,
527        key: &fact.key,
528    };
529    bindings::bind(&item, &fact.value, project_root, sources)
530}
531
532/// OKF export/import take a caller-supplied `path`. Like ctx_read and
533/// ctx_handoff, keep it inside the project jail; a relative path is taken from
534/// the project root.
535fn jailed_path(tool: &str, raw: &str, project_root: &str) -> Result<String, String> {
536    let root = std::path::Path::new(project_root);
537    let candidate = std::path::Path::new(raw);
538    let candidate = if candidate.is_absolute() {
539        candidate.to_path_buf()
540    } else {
541        root.join(candidate)
542    };
543    lean_ctx::core::io_boundary::jail_and_check_path(tool, &candidate, root)
544        .map(|(p, _warning)| p.to_string_lossy().into_owned())
545}
546
547/// A plain text `ToolOutput` tagged with the action as its mode. `ctx_knowledge`
548/// results are already compressed prose, so token accounting is left at zero.
549fn text_output(action: &str, text: String) -> ToolOutput {
550    ToolOutput {
551        text,
552        original_tokens: 0,
553        saved_tokens: 0,
554        mode: Some(action.to_string()),
555        path: None,
556        changed: false,
557        shell_outcome: None,
558        content_blocks: None,
559    }
560}
561
562#[cfg(test)]
563mod source_binding_tests {
564    use super::*;
565    use lean_ctx::core::archive::bindings::{self, Item};
566    use lean_ctx::core::context_admission::stored::lineage_support::{
567        deliver, governed, project, reusable,
568    };
569
570    fn remember(root: &str, key: &str, value: &str) {
571        let reply = crate::tools::ctx_knowledge::handle(
572            root,
573            "remember",
574            Some("architecture"),
575            Some(key),
576            Some(value),
577            None,
578            "session-1",
579            None,
580            None,
581            None,
582            None,
583            None,
584        );
585        assert!(!reply.starts_with("ERROR"), "{reply}");
586    }
587
588    /// `remember` with a readable source binds the stored fact; with a source
589    /// that is not admitted, or for a value other than the stored one, the
590    /// fact stays stored and originless.
591    #[test]
592    fn remember_binds_the_stored_fact_to_the_sources_named_with_it() {
593        let _data = governed();
594        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
595        let (_directory, root) = project();
596        std::fs::write(format!("{root}/auth.rs"), "pub fn verify_rs256() {}\n").expect("source");
597        std::fs::write(format!("{root}/plan.md"), "*** TOP SECRET ***\nlaunch\n").expect("marked");
598        let value = "Tokens are verified with RS256 in the auth module";
599        remember(&root, "token-check", value);
600        let key = |sources: &[&str]| {
601            let sources: Vec<String> = sources.iter().map(|path| (*path).to_owned()).collect();
602            bind_remembered(
603                &root,
604                Some("architecture"),
605                Some("token-check"),
606                Some(value),
607                &sources,
608            )
609        };
610        assert!(!key(&["plan.md"]), "a restricted source binds nothing");
611        assert!(!key(&["auth.rs", "missing.rs"]), "all sources or none");
612        assert!(!bind_remembered(
613            &root,
614            Some("architecture"),
615            Some("token-check"),
616            Some("another value"),
617            &["auth.rs".to_owned()],
618        ));
619        let item = Item::KnowledgeFact {
620            project: &root,
621            category: "architecture",
622            key: "token-check",
623        };
624        let recall = || {
625            deliver(&root, || {
626                bindings::serve(&item, value, Some(&root)).expect("served");
627                format!("[architecture/token-check]: {value}")
628            })
629        };
630        assert_eq!(recall().1, 0, "stored, and originless so far");
631
632        assert!(key(&["auth.rs"]));
633        let (reply, recorded) = recall();
634        assert_eq!(recorded, 1);
635        assert_eq!(reusable(&root, &reply), Ok(()));
636    }
637
638    fn git(dir: &str, args: &[&str]) {
639        let status = std::process::Command::new("git")
640            .args(["-c", "user.name=t", "-c", "user.email=t@example.invalid"])
641            .args(args)
642            .current_dir(dir)
643            .env_remove("GIT_DIR")
644            .env_remove("GIT_WORK_TREE")
645            .status()
646            .expect("git runs");
647        assert!(status.success(), "git {args:?}");
648    }
649
650    /// S05 (AP01 §4): two worktrees of one repository, linked to one project
651    /// (D01). A convention is served in both regardless of branch. A fact
652    /// bound to `retry.rs` is served in its own worktree, withheld as stale
653    /// in the other while the file differs there on its branch, and served
654    /// there once the file is identical. An unclassified fact stays in its
655    /// own worktree, as before classes.
656    #[test]
657    fn linked_worktrees_share_conventions_and_withhold_stale_code() {
658        use lean_ctx::core::project_link::{ProjectLink, link, link_time};
659        let _data = governed();
660        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
661        let (_main_dir, main) = project();
662        let (_other_dir, other_parent) = project();
663        let feature = format!("{other_parent}/feature");
664        std::fs::write(format!("{main}/retry.rs"), "fn retry() { /* bounded */ }\n").unwrap();
665        git(&main, &["init", "-q", "-b", "main"]);
666        git(&main, &["add", "retry.rs"]);
667        git(&main, &["commit", "-q", "-m", "retry"]);
668        git(&main, &["worktree", "add", "-q", "-b", "feature", &feature]);
669        let feature = lean_ctx::core::pathutil::safe_canonicalize(std::path::Path::new(&feature))
670            .unwrap()
671            .to_str()
672            .unwrap()
673            .to_owned();
674        std::fs::write(format!("{feature}/retry.rs"), "fn retry() { loop {} }\n").unwrap();
675        git(&feature, &["commit", "-q", "-am", "unbounded"]);
676        for root in [&main, &feature] {
677            link(ProjectLink {
678                workspace_root: root.clone(),
679                team_project_id: "gateway".into(),
680                organization_id: None,
681                label: None,
682                linked_at: link_time(),
683            })
684            .unwrap();
685        }
686
687        let convention = "Branch names follow the pattern type slash slug";
688        let code = "The retry loop in retry.rs is bounded by its budget";
689        let local = "The release captain promotes deployments on Fridays";
690        let remember_in = |category: &str, key: &str, value: &str| {
691            let reply = crate::tools::ctx_knowledge::handle(
692                &main,
693                "remember",
694                Some(category),
695                Some(key),
696                Some(value),
697                None,
698                "session-1",
699                None,
700                None,
701                None,
702                None,
703                None,
704            );
705            assert!(reply.starts_with("Remembered"), "{reply}");
706        };
707        remember_in("decision", "branch-naming", convention);
708        remember_in("architecture", "retry-bound", code);
709        remember_in("architecture", "release-duty", local);
710        let reply = remembered_with(
711            String::new(),
712            &main,
713            Some("architecture"),
714            Some("retry-bound"),
715            Some(code),
716            &["retry.rs".to_owned()],
717            None,
718        );
719        assert!(reply.contains(SOURCES_BOUND), "{reply}");
720        assert!(reply.contains("validity: content"), "{reply}");
721
722        let recall = |root: &str| {
723            crate::tools::ctx_knowledge::handle(
724                root,
725                "recall",
726                None,
727                None,
728                None,
729                None,
730                "session-1",
731                None,
732                None,
733                None,
734                None,
735                None,
736            )
737        };
738        let own = recall(&main);
739        assert!(own.contains(&format!("{convention} (confidence")), "{own}");
740        assert!(own.contains("[convention]") && own.contains(code), "{own}");
741        assert!(own.contains(local), "{own}");
742
743        let linked = recall(&feature);
744        assert!(
745            linked.contains(&format!("{convention} [convention]")),
746            "{linked}"
747        );
748        assert!(!linked.contains(code), "{linked}");
749        assert!(
750            linked.contains("1 code-bound fact(s) withheld: stale"),
751            "{linked}"
752        );
753        assert!(!linked.contains(local), "{linked}");
754
755        std::fs::write(
756            format!("{feature}/retry.rs"),
757            "fn retry() { /* bounded */ }\n",
758        )
759        .unwrap();
760        let same = recall(&feature);
761        assert!(same.contains(&format!("{code} [content]")), "{same}");
762        assert!(!same.contains("withheld"), "{same}");
763    }
764
765    fn declares(action: &str) -> bool {
766        let mut args = Map::new();
767        args.insert("action".to_owned(), Value::String(action.to_owned()));
768        CtxKnowledgeTool.publishes_every_source(Some(&args))
769    }
770
771    /// The recall of the knowledge tool itself, as a declared action
772    /// delivers it.
773    fn recall(root: &str) -> (String, usize) {
774        deliver(root, || {
775            crate::tools::ctx_knowledge::handle(
776                root,
777                "recall",
778                Some("architecture"),
779                None,
780                None,
781                None,
782                "session-1",
783                None,
784                None,
785                None,
786                None,
787                None,
788            )
789        })
790    }
791
792    /// A fact remembered with sources and recalled by a declared action
793    /// gives the reply the lineage of its files. A fact without sources in
794    /// the same reply leaves it unknown. When a source is withheld by a
795    /// tightened rule, or deleted, the lineage refuses and the bound fact is
796    /// no longer recalled — the fact without sources still is.
797    #[test]
798    fn a_declared_recall_carries_the_lineage_of_its_bound_facts() {
799        use lean_ctx::core::archive::delivered::LineageRefusal;
800        let _data = governed();
801        let _community = lean_ctx::core::policy::runtime::TestPolicyOverride::set(None);
802        let (_directory, root) = project();
803        std::fs::write(format!("{root}/auth.rs"), "pub fn verify_rs256() {}\n").expect("source");
804        let value = "Tokens are verified with RS256 in the auth module";
805        remember(&root, "token-check", value);
806        assert!(bind_remembered(
807            &root,
808            Some("architecture"),
809            Some("token-check"),
810            Some(value),
811            &["auth.rs".to_owned()],
812        ));
813        assert!(declares("recall") && declares("timeline") && declares("search"));
814        assert!(!declares("remember") && !declares("status") && !declares("wakeup"));
815
816        let (reply, recorded) = recall(&root);
817        assert!(reply.contains(value), "{reply}");
818        assert_eq!(recorded, 1);
819        assert_eq!(reusable(&root, &reply), Ok(()));
820
821        let typed = "Deployments are promoted by the release captain on Fridays";
822        remember(&root, "release-duty", typed);
823        let (mixed, recorded) = recall(&root);
824        assert!(mixed.contains(value) && mixed.contains(typed), "{mixed}");
825        assert_eq!(recorded, 0, "one fact without a source: unknown");
826        assert_eq!(reusable(&root, &mixed), Err(LineageRefusal::Unknown));
827
828        // The source is reclassified: the recorded reply is refused and the
829        // bound fact is left out of the next recall.
830        std::fs::write(
831            format!("{root}/auth.rs"),
832            "*** TOP SECRET ***\npub fn verify_rs256() {}\n",
833        )
834        .expect("mark");
835        assert_eq!(
836            reusable(&root, &reply),
837            Err(LineageRefusal::SourceNoLongerAdmitted)
838        );
839        let (withheld, recorded) = recall(&root);
840        assert!(!withheld.contains(value), "{withheld}");
841        assert!(withheld.contains(typed), "{withheld}");
842        assert_eq!(recorded, 0);
843
844        std::fs::remove_file(format!("{root}/auth.rs")).expect("delete");
845        assert_eq!(
846            reusable(&root, &reply),
847            Err(LineageRefusal::SourceNoLongerAdmitted)
848        );
849        assert!(!recall(&root).0.contains(value));
850    }
851}
852
853// The jail is the point of these tests; a `no-jail` build switches it off.
854#[cfg(all(test, not(feature = "no-jail")))]
855mod tests {
856    use super::*;
857
858    fn call(ctx: &ToolContext, args: &Value) -> String {
859        CtxKnowledgeTool
860            .handle(args.as_object().unwrap(), ctx)
861            .unwrap()
862            .text
863    }
864
865    #[test]
866    fn okf_export_and_import_paths_stay_in_the_project_jail() {
867        let _data = lean_ctx::core::data_dir::isolated_data_dir();
868        let root = tempfile::tempdir().unwrap();
869        let outside = tempfile::tempdir().unwrap();
870        let ctx = ToolContext {
871            project_root: root.path().to_string_lossy().into_owned(),
872            session: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
873                lean_ctx::core::session::SessionState::new(),
874            ))),
875            ..ToolContext::default()
876        };
877        call(
878            &ctx,
879            &json!({"action": "remember", "category": "architecture",
880                    "key": "parser", "value": "the parser is bounded"}),
881        );
882
883        let export_dir = outside.path().join("okf");
884        let text = call(
885            &ctx,
886            &json!({"action": "export", "format": "okf", "path": export_dir}),
887        );
888        assert!(!export_dir.exists(), "OKF export left the jail: {text}");
889
890        let facts = outside.path().join("facts.json");
891        std::fs::write(
892            &facts,
893            r#"[{"category":"secret","key":"outside","value":"OUTSIDE-JAIL"}]"#,
894        )
895        .unwrap();
896        let text = call(&ctx, &json!({"action": "import", "path": facts}));
897        assert!(
898            !text.contains("Import complete"),
899            "import read outside the jail: {text}"
900        );
901
902        let inside = root.path().join("okf-export");
903        let text = call(
904            &ctx,
905            &json!({"action": "export", "format": "okf", "path": inside}),
906        );
907        assert!(inside.join("index.md").exists(), "{text}");
908        let text = call(&ctx, &json!({"action": "import", "path": inside}));
909        assert!(text.contains("import"), "{text}");
910    }
911
912    /// #270: a fact `ctx_knowledge` remembers is handed to the push on write
913    /// of this process (which pushes the vault within about a second); a
914    /// read that writes nothing is not.
915    #[test]
916    fn a_remembered_fact_asks_for_a_push_and_a_recall_does_not() {
917        use lean_ctx_hive::sync::on_write::requested;
918        let _data = lean_ctx::core::data_dir::isolated_data_dir();
919        let dir = tempfile::tempdir().unwrap();
920        let root = dir
921            .path()
922            .canonicalize()
923            .unwrap()
924            .to_string_lossy()
925            .into_owned();
926        let ctx = ToolContext {
927            project_root: root.clone(),
928            session: Some(std::sync::Arc::new(tokio::sync::RwLock::new(
929                lean_ctx::core::session::SessionState::new(),
930            ))),
931            ..ToolContext::default()
932        };
933        assert_eq!(requested(&root), 0);
934        call(
935            &ctx,
936            &json!({"action": "remember", "category": "architecture",
937                    "key": "retry", "value": "payments retry three times"}),
938        );
939        assert_eq!(requested(&root), 1, "the write asks for a push");
940        call(&ctx, &json!({"action": "recall", "query": "retry"}));
941        assert_eq!(requested(&root), 1, "a recall writes nothing");
942    }
943}