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