Skip to main content

caliban_memory/
ancestry_addendum.rs

1//! Nested-on-demand CLAUDE.md loader.
2//!
3//! After the model `Read`s a file under subdirectory X, any `CLAUDE.md` (or
4//! `AGENTS.md` / `.caliban.md`) found in X or its ancestors (between the
5//! workspace root and X, exclusive of the root walk's already-loaded files)
6//! is added as a system-prompt addendum for the rest of the session.
7//!
8//! Part of ADR 0036.
9
10use std::collections::BTreeSet;
11use std::path::{Path, PathBuf};
12use std::sync::Mutex;
13
14use crate::loader::estimate_tokens;
15use crate::prefix::TierFile;
16use crate::project_walk::{ANCESTRY_FILENAMES, WalkStop};
17
18/// Session-scoped tracker for CLAUDE.md files discovered on-demand by
19/// `Read`/`Edit`/`Glob` hooks.
20#[derive(Debug)]
21pub struct AncestryAddendum {
22    workspace_root: PathBuf,
23    walk_stop: WalkStop,
24    /// Files already accounted for (initial walk + prior addendums).
25    loaded: Mutex<BTreeSet<PathBuf>>,
26}
27
28impl AncestryAddendum {
29    /// Build an addendum tracker primed with `initial_loaded` — the set of
30    /// CLAUDE.md files already loaded by the startup walk. Future
31    /// `on_path_touched` calls dedupe against this set.
32    #[must_use]
33    pub fn new(
34        workspace_root: PathBuf,
35        walk_stop: WalkStop,
36        initial_loaded: impl IntoIterator<Item = PathBuf>,
37    ) -> Self {
38        Self {
39            workspace_root,
40            walk_stop,
41            loaded: Mutex::new(initial_loaded.into_iter().collect()),
42        }
43    }
44
45    /// Notification entrypoint: the model touched `path` (via Read / Edit /
46    /// Glob). Returns any **newly discovered** CLAUDE.md / AGENTS.md /
47    /// `.caliban.md` files in `path`'s ancestry, between the workspace root
48    /// (inclusive) and `path`'s parent (inclusive). Already-loaded files are
49    /// elided.
50    ///
51    /// Returns `None` when nothing new was discovered.
52    ///
53    /// # Panics
54    ///
55    /// Panics only if the internal `Mutex` is poisoned (another thread
56    /// panicked while holding the lock).
57    pub fn on_path_touched(&self, path: &Path) -> Option<Vec<TierFile>> {
58        let mut new_files = Vec::new();
59        let mut current = path.parent().map(Path::to_path_buf);
60
61        let mut loaded = self.loaded.lock().expect("addendum mutex poisoned");
62        while let Some(dir) = current.clone() {
63            for name in ANCESTRY_FILENAMES {
64                let candidate = dir.join(name);
65                if !candidate.is_file() {
66                    continue;
67                }
68                if !loaded.insert(candidate.clone()) {
69                    continue;
70                }
71                let body = match std::fs::read(&candidate) {
72                    Ok(bytes) => String::from_utf8_lossy(&bytes).into_owned(),
73                    Err(e) => {
74                        tracing::warn!(
75                            target: caliban_common::tracing_targets::TARGET_MEMORY,
76                            path = %candidate.display(),
77                            error = %e,
78                            "failed to read nested CLAUDE.md",
79                        );
80                        continue;
81                    }
82                };
83                let estimated_tokens = estimate_tokens(&body);
84                new_files.push(TierFile {
85                    path: candidate,
86                    body,
87                    estimated_tokens,
88                    truncated_bytes: 0,
89                });
90            }
91            // Stop when we've crossed the workspace root.
92            if dir == self.workspace_root {
93                break;
94            }
95            // Stop also when the walk_stop boundary says so (e.g. .git/).
96            if matches!(self.walk_stop, WalkStop::GitRoot | WalkStop::Both)
97                && dir.join(".git").exists()
98            {
99                break;
100            }
101            current = dir.parent().map(Path::to_path_buf);
102        }
103
104        if new_files.is_empty() {
105            None
106        } else {
107            Some(new_files)
108        }
109    }
110}
111
112#[cfg(test)]
113mod tests {
114    use super::*;
115    use std::fs;
116    use tempfile::TempDir;
117
118    #[test]
119    fn nested_on_demand_returns_subtree_claude_md() {
120        let tmp = TempDir::new().unwrap();
121        let root = tmp.path();
122        fs::create_dir_all(root.join(".git")).unwrap();
123        let sub = root.join("backend");
124        fs::create_dir_all(&sub).unwrap();
125        fs::write(sub.join("CLAUDE.md"), "BACKEND-CONVENTIONS").unwrap();
126
127        let addendum =
128            AncestryAddendum::new(root.to_path_buf(), WalkStop::GitRoot, std::iter::empty());
129        let touched = sub.join("server.rs");
130        fs::write(&touched, "fn main() {}").unwrap();
131        let new_files = addendum.on_path_touched(&touched).expect("loaded");
132        assert_eq!(new_files.len(), 1);
133        assert!(new_files[0].body.contains("BACKEND-CONVENTIONS"));
134    }
135
136    #[test]
137    fn nested_on_demand_dedupes_after_first_touch() {
138        let tmp = TempDir::new().unwrap();
139        let root = tmp.path();
140        fs::create_dir_all(root.join(".git")).unwrap();
141        let sub = root.join("backend");
142        fs::create_dir_all(&sub).unwrap();
143        fs::write(sub.join("CLAUDE.md"), "X").unwrap();
144
145        let addendum =
146            AncestryAddendum::new(root.to_path_buf(), WalkStop::GitRoot, std::iter::empty());
147        let f1 = sub.join("a.rs");
148        let f2 = sub.join("b.rs");
149        fs::write(&f1, "").unwrap();
150        fs::write(&f2, "").unwrap();
151        let first = addendum.on_path_touched(&f1);
152        let second = addendum.on_path_touched(&f2);
153        assert!(first.is_some());
154        assert!(
155            second.is_none(),
156            "second touch should not re-load: {second:?}"
157        );
158    }
159
160    #[test]
161    fn nested_on_demand_skips_files_initial_walk_already_loaded() {
162        let tmp = TempDir::new().unwrap();
163        let root = tmp.path();
164        fs::create_dir_all(root.join(".git")).unwrap();
165        let sub = root.join("backend");
166        fs::create_dir_all(&sub).unwrap();
167        fs::write(sub.join("CLAUDE.md"), "BACKEND").unwrap();
168
169        // Pretend the initial walk already saw sub/CLAUDE.md.
170        let addendum = AncestryAddendum::new(
171            root.to_path_buf(),
172            WalkStop::GitRoot,
173            std::iter::once(sub.join("CLAUDE.md")),
174        );
175        let touched = sub.join("server.rs");
176        fs::write(&touched, "").unwrap();
177        let new_files = addendum.on_path_touched(&touched);
178        assert!(new_files.is_none(), "already-loaded file should be skipped");
179    }
180
181    #[test]
182    fn nested_on_demand_stops_at_workspace_root() {
183        let tmp = TempDir::new().unwrap();
184        let outer = tmp.path();
185        let workspace = outer.join("ws");
186        let sub = workspace.join("a").join("b");
187        fs::create_dir_all(&sub).unwrap();
188        // CLAUDE.md OUTSIDE the workspace must not be picked up.
189        fs::write(outer.join("CLAUDE.md"), "OUTSIDE").unwrap();
190        fs::write(workspace.join("CLAUDE.md"), "WS").unwrap();
191        fs::write(sub.join("CLAUDE.md"), "SUB").unwrap();
192
193        let addendum =
194            AncestryAddendum::new(workspace.clone(), WalkStop::FsRoot, std::iter::empty());
195        let touched = sub.join("file.rs");
196        fs::write(&touched, "").unwrap();
197        let new_files = addendum.on_path_touched(&touched).expect("loaded");
198        let bodies: Vec<_> = new_files.iter().map(|f| f.body.as_str()).collect();
199        assert!(
200            bodies.contains(&"WS"),
201            "workspace CLAUDE.md loaded: {bodies:?}"
202        );
203        assert!(bodies.contains(&"SUB"));
204        assert!(
205            !bodies.iter().any(|b| b == &"OUTSIDE"),
206            "must not cross workspace root: {bodies:?}",
207        );
208    }
209}