Skip to main content

claudectl_core/
focus.rs

1//! Where in its project a session is working (#596).
2//!
3//! A session's *focus* is the deepest directory under its worktree root
4//! that holds a strict majority of its recent edits. It is derived from the
5//! edit tools' inputs alone (`transcript::EDIT_TOOLS`: `Edit`, `Write`,
6//! `MultiEdit`, `NotebookEdit`): no LLM, no heuristics about names,
7//! integers end to end. A strict majority makes the
8//! answer unique by construction (two sibling directories cannot both hold
9//! more than half), and the root always qualifies, so a session with any
10//! in-tree edit has a focus and one with none has `None`.
11//!
12//! Edits outside the root are not the project's work and are left out of
13//! the total, so a session that also touches `~/.claude/CLAUDE.md` is still
14//! focused on the directory it edits inside the repo.
15//!
16//! The bus uses the focus to route work to whichever role is bound to the
17//! enclosing subtree; see `bus::roles::owner_of_path` in the binary.
18
19use std::collections::HashMap;
20use std::path::{Path, PathBuf};
21
22use crate::paths::{canonical_spellings, canonicalize_deep, normalize_lexically};
23use crate::transcript::{
24    TranscriptBlock, TranscriptEvent, TranscriptRole, edited_path, is_edit_tool, parse_line,
25    read_tail,
26};
27
28/// Bytes of transcript tail scanned for edits. Recent edits say what a
29/// session is doing *now*; the bus process never runs the monitor, so this
30/// is a bounded read rather than an incremental one.
31pub const TRANSCRIPT_SCAN_BYTES: u64 = 2 * 1024 * 1024;
32
33/// The directory a session's edits concentrate in, relative to the root.
34#[derive(Debug, Clone, PartialEq, Eq)]
35pub struct Focus {
36    /// Directory relative to the root, `/`-separated; `.` is the root itself.
37    pub dir: String,
38    /// Edits under `dir` (every edit counts once per enclosing directory).
39    pub edits: u32,
40    /// Edits under the root in total.
41    pub total: u32,
42}
43
44impl Focus {
45    /// The focus as an absolute path under `root`.
46    pub fn path_under(&self, root: &Path) -> PathBuf {
47        if self.dir == "." {
48            root.to_path_buf()
49        } else {
50            root.join(&self.dir)
51        }
52    }
53}
54
55/// The deepest directory holding a strict majority of `edits` under `root`.
56///
57/// `edits` is `(file path, edit count)`; paths are matched against `root`
58/// as given and against its canonical form, because tool inputs may say
59/// `/tmp/…` where `git rev-parse` says `/private/tmp/…`.
60pub fn focus_from_edits<'a>(
61    edits: impl IntoIterator<Item = (&'a str, u32)>,
62    root: &Path,
63) -> Option<Focus> {
64    let roots = root_forms(root);
65    let mut per_dir: HashMap<String, u32> = HashMap::new();
66    let mut total = 0u32;
67    for (file, count) in edits {
68        let Some(rel) = relative_to_any(file, &roots) else {
69            continue;
70        };
71        let Some(dir) = parent_dir(&rel) else {
72            continue;
73        };
74        total = total.saturating_add(count);
75        for ancestor in ancestors(dir) {
76            *per_dir.entry(ancestor).or_insert(0) += count;
77        }
78    }
79    if total == 0 {
80        return None;
81    }
82    per_dir
83        .into_iter()
84        .filter(|(_, n)| u64::from(*n) * 2 > u64::from(total))
85        .max_by_key(|(dir, _)| depth(dir))
86        .map(|(dir, edits)| Focus { dir, edits, total })
87}
88
89/// Edit counts per file from the tail of a transcript, keyed by the path
90/// the tool was given. Reads at most `cap` bytes; an unreadable file is an
91/// empty map, since a missing focus is the honest answer there.
92pub fn edits_from_transcript(jsonl_path: &Path, cap: u64) -> HashMap<String, u32> {
93    let text = read_tail(jsonl_path, cap).unwrap_or_default();
94    let mut edits = HashMap::new();
95    for line in text.lines() {
96        // A line without a tool call cannot carry an edit; skip the JSON
97        // parse for the tool results and text turns that make up most of a
98        // transcript by bytes.
99        if !line.contains("tool_use") {
100            continue;
101        }
102        let Some(TranscriptEvent::Message(msg)) = parse_line(line) else {
103            continue;
104        };
105        if msg.role != TranscriptRole::Assistant {
106            continue;
107        }
108        for block in &msg.content {
109            let TranscriptBlock::ToolUse { name, input } = block else {
110                continue;
111            };
112            if !is_edit_tool(name) {
113                continue;
114            }
115            if let Some(p) = edited_path(input) {
116                *edits.entry(p.to_string()).or_insert(0) += 1;
117            }
118        }
119    }
120    edits
121}
122
123/// The focus of a session: its transcript tail against its worktree root.
124///
125/// The window starts at [`TRANSCRIPT_SCAN_BYTES`] and grows four-fold, up
126/// to [`TRANSCRIPT_SCAN_MAX_BYTES`], while it holds no edit at all and the
127/// file has more to give. A single multi-MB tool result (a build log) can
128/// otherwise fill the whole window and report `None` for a session that
129/// edited ten files just before it.
130pub fn session_focus(jsonl_path: Option<&Path>, root: &Path) -> Option<Focus> {
131    session_focus_within(
132        jsonl_path?,
133        root,
134        TRANSCRIPT_SCAN_BYTES,
135        TRANSCRIPT_SCAN_MAX_BYTES,
136    )
137}
138
139/// Largest transcript tail `session_focus` will read when smaller windows
140/// hold no edit.
141pub const TRANSCRIPT_SCAN_MAX_BYTES: u64 = 32 * 1024 * 1024;
142
143/// `session_focus` with the window bounds given, so a test can make the
144/// first window miss.
145pub fn session_focus_within(jsonl_path: &Path, root: &Path, cap: u64, max: u64) -> Option<Focus> {
146    let len = std::fs::metadata(jsonl_path).map(|m| m.len()).unwrap_or(0);
147    let mut cap = cap.max(1);
148    loop {
149        let edits = edits_from_transcript(jsonl_path, cap);
150        // Stop on an *in-tree* edit, which is what a focus is made of: an
151        // out-of-tree edit in the window (a note in `~/.claude`) is not a
152        // reason to stop looking for the project's own.
153        let focus = focus_from_edits(edits.iter().map(|(p, n)| (p.as_str(), *n)), root);
154        if focus.is_some() || cap >= len || cap >= max {
155            return focus;
156        }
157        cap = cap.saturating_mul(4).min(max);
158    }
159}
160
161/// The root as given and as it resolves on disk, deduplicated, with no
162/// trailing slash so the prefix test below is one shape.
163fn root_forms(root: &Path) -> Vec<String> {
164    let mut forms = vec![trim_slash(&root.to_string_lossy())];
165    let c = trim_slash(&canonicalize_deep(root).to_string_lossy());
166    if !forms.contains(&c) {
167        forms.push(c);
168    }
169    forms
170}
171
172fn trim_slash(s: &str) -> String {
173    let t = s.trim_end_matches('/');
174    if t.is_empty() {
175        "/".to_string()
176    } else {
177        t.to_string()
178    }
179}
180
181/// `file` relative to the first root it sits under, or `None` when it is
182/// outside every form of the root. The file is tried as spelled and in its
183/// canonical form, because `git rev-parse` reports a canonical root while a
184/// tool input follows the session's cwd, which may run through a symlink.
185fn relative_to_any(file: &str, roots: &[String]) -> Option<String> {
186    // Lexically normalized first (`.` and `..` folded, so a path that
187    // escapes the root cannot pass a string prefix test): that is the common
188    // case and costs no syscall. The on-disk forms are computed only when no
189    // root matched.
190    let normalized = normalize_lexically(Path::new(file));
191    let literal = normalized.to_string_lossy();
192    if let Some(rel) = roots.iter().find_map(|root| strip_root(&literal, root)) {
193        return Some(rel.to_string());
194    }
195    canonical_spellings(&normalized)
196        .iter()
197        .find_map(|f| roots.iter().find_map(|root| strip_root(f, root)))
198        .map(str::to_string)
199}
200
201fn strip_root<'a>(file: &'a str, root: &str) -> Option<&'a str> {
202    if root == "/" {
203        return file.strip_prefix('/');
204    }
205    file.strip_prefix(root)
206        .and_then(|rest| rest.strip_prefix('/'))
207}
208
209/// The directory part of a relative file path, `.` for a top-level file.
210fn parent_dir(rel: &str) -> Option<&str> {
211    let rel = rel.trim_end_matches('/');
212    if rel.is_empty() {
213        return None;
214    }
215    Some(match rel.rfind('/') {
216        Some(i) => &rel[..i],
217        None => ".",
218    })
219}
220
221/// Every directory enclosing `dir`, from the root (`.`) down to `dir`.
222fn ancestors(dir: &str) -> Vec<String> {
223    let mut out = vec![".".to_string()];
224    if dir == "." {
225        return out;
226    }
227    let mut acc = String::new();
228    for seg in dir.split('/').filter(|s| !s.is_empty()) {
229        if !acc.is_empty() {
230            acc.push('/');
231        }
232        acc.push_str(seg);
233        out.push(acc.clone());
234    }
235    out
236}
237
238fn depth(dir: &str) -> usize {
239    if dir == "." {
240        0
241    } else {
242        dir.split('/').count()
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    fn focus(edits: &[(&str, u32)], root: &str) -> Option<Focus> {
251        focus_from_edits(edits.iter().copied(), Path::new(root))
252    }
253
254    #[test]
255    fn the_deepest_majority_directory_is_the_focus() {
256        let f = focus(
257            &[
258                ("/repo/src/relay/peer.rs", 3),
259                ("/repo/src/relay/mesh.rs", 2),
260                ("/repo/src/bus/mcp.rs", 1),
261            ],
262            "/repo",
263        )
264        .unwrap();
265        assert_eq!(f.dir, "src/relay");
266        assert_eq!((f.edits, f.total), (5, 6));
267    }
268
269    #[test]
270    fn an_even_split_stops_at_the_common_parent() {
271        let f = focus(
272            &[("/repo/src/relay/peer.rs", 2), ("/repo/src/bus/mcp.rs", 2)],
273            "/repo",
274        )
275        .unwrap();
276        assert_eq!(f.dir, "src", "neither sibling holds a strict majority");
277    }
278
279    #[test]
280    fn work_spread_across_the_tree_is_the_root() {
281        let f = focus(
282            &[
283                ("/repo/README.md", 1),
284                ("/repo/src/a.rs", 1),
285                ("/repo/docs/b.md", 1),
286            ],
287            "/repo",
288        )
289        .unwrap();
290        assert_eq!(f.dir, ".");
291        assert_eq!(f.path_under(Path::new("/repo")), PathBuf::from("/repo"));
292    }
293
294    #[test]
295    fn edits_outside_the_root_do_not_count() {
296        let f = focus(
297            &[
298                ("/home/me/.claude/CLAUDE.md", 10),
299                ("/repo-other/src/x.rs", 10),
300                ("/repo/src/relay/peer.rs", 1),
301            ],
302            "/repo",
303        )
304        .unwrap();
305        assert_eq!(f.dir, "src/relay");
306        assert_eq!(f.total, 1, "a sibling repo with the same prefix is outside");
307    }
308
309    #[test]
310    fn no_in_tree_edits_is_no_focus() {
311        assert_eq!(focus(&[], "/repo"), None);
312        assert_eq!(focus(&[("/elsewhere/x.rs", 4)], "/repo"), None);
313    }
314
315    #[test]
316    fn a_trailing_slash_on_the_root_is_harmless() {
317        let f = focus(&[("/repo/src/a.rs", 1)], "/repo/").unwrap();
318        assert_eq!(f.dir, "src");
319        assert_eq!(f.path_under(Path::new("/repo")), PathBuf::from("/repo/src"));
320    }
321
322    #[test]
323    fn the_canonical_root_matches_too() {
324        // Tool inputs may name the resolved form of a root that is given
325        // through a symlink (macOS `/tmp` → `/private/tmp`).
326        let tmp = std::env::temp_dir().join(format!("focus-{}", std::process::id()));
327        std::fs::create_dir_all(tmp.join("src")).unwrap();
328        let canon = std::fs::canonicalize(&tmp).unwrap();
329        let file = format!("{}/src/lib.rs", canon.display());
330        let f = focus(&[(file.as_str(), 2)], &tmp.to_string_lossy()).unwrap();
331        assert_eq!(f.dir, "src");
332        let _ = std::fs::remove_dir_all(&tmp);
333    }
334
335    #[test]
336    fn a_canonical_root_matches_a_file_spelled_through_the_symlink() {
337        // The shape `list_agents` meets: `git rev-parse` gives the canonical
338        // root, the transcript's tool inputs follow the session cwd (macOS
339        // `/var/folders/…` → `/private/var/folders/…`), and the file may not
340        // exist yet. On a platform with no symlink here both spellings are
341        // equal and the test still holds.
342        let tmp = std::env::temp_dir().join(format!("focus-rev-{}", std::process::id()));
343        std::fs::create_dir_all(tmp.join("src")).unwrap();
344        let canon_root = std::fs::canonicalize(&tmp).unwrap();
345        let file = format!("{}/src/new.rs", tmp.display());
346        let f = focus(&[(file.as_str(), 1)], &canon_root.to_string_lossy()).unwrap();
347        assert_eq!(f.dir, "src");
348        let _ = std::fs::remove_dir_all(&tmp);
349    }
350
351    #[test]
352    fn transcript_edits_count_only_editing_tools_by_the_assistant() {
353        let tmp = std::env::temp_dir().join(format!("focus-jsonl-{}", std::process::id()));
354        let lines = [
355            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#,
356            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Read","input":{"file_path":"/repo/src/bus/mcp.rs"}}]}}"#,
357            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"NotebookEdit","input":{"notebook_path":"/repo/src/relay/nb.ipynb"}}]}}"#,
358            r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Write","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#,
359            r#"{"type":"user","message":{"role":"user","content":"please edit /repo/src/bus/x.rs"}}"#,
360        ];
361        std::fs::write(&tmp, lines.join("\n")).unwrap();
362        let edits = edits_from_transcript(&tmp, TRANSCRIPT_SCAN_BYTES);
363        assert_eq!(edits.get("/repo/src/relay/peer.rs"), Some(&2));
364        assert_eq!(edits.get("/repo/src/relay/nb.ipynb"), Some(&1));
365        assert_eq!(
366            edits.get("/repo/src/bus/mcp.rs"),
367            None,
368            "reads are not edits"
369        );
370        assert_eq!(edits.len(), 2);
371        let f = session_focus(Some(&tmp), Path::new("/repo")).unwrap();
372        assert_eq!(f.dir, "src/relay");
373        let _ = std::fs::remove_file(&tmp);
374    }
375
376    #[test]
377    fn a_capped_read_keeps_the_tail() {
378        let tmp = std::env::temp_dir().join(format!("focus-tail-{}", std::process::id()));
379        let old = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/old/a.rs"}}]}}"#;
380        let new = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/new/b.rs"}}]}}"#;
381        std::fs::write(&tmp, format!("{old}\n{new}\n")).unwrap();
382        let edits = edits_from_transcript(&tmp, (new.len() + 1) as u64);
383        assert_eq!(edits.get("/repo/new/b.rs"), Some(&1));
384        assert_eq!(
385            edits.get("/repo/old/a.rs"),
386            None,
387            "the head fell outside the cap"
388        );
389        let _ = std::fs::remove_file(&tmp);
390    }
391
392    #[test]
393    fn a_path_that_escapes_the_root_is_outside_and_dots_do_not_double_count() {
394        assert_eq!(
395            focus(&[("/repo/../sibling/x.rs", 3)], "/repo"),
396            None,
397            "`..` out of the root is not in the tree"
398        );
399        let f = focus(
400            &[("/repo/./src/./x.rs", 1), ("/repo/src/../src/y.rs", 1)],
401            "/repo",
402        )
403        .unwrap();
404        assert_eq!(f.dir, "src");
405        assert_eq!((f.edits, f.total), (2, 2));
406    }
407
408    #[test]
409    fn an_out_of_tree_edit_in_the_window_does_not_stop_the_search() {
410        let tmp = std::env::temp_dir().join(format!("focus-grow2-{}", std::process::id()));
411        let in_tree = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#;
412        let log = "x".repeat(4096);
413        let result = format!(
414            r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","content":"{log}"}}]}}}}"#
415        );
416        let out_of_tree = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/home/me/.claude/CLAUDE.md"}}]}}"#;
417        std::fs::write(&tmp, format!("{in_tree}\n{result}\n{out_of_tree}\n")).unwrap();
418        let small = (result.len() + out_of_tree.len() + 2) as u64;
419        assert_eq!(
420            edits_from_transcript(&tmp, small).len(),
421            1,
422            "only the out-of-tree edit fits"
423        );
424        let f = session_focus_within(&tmp, Path::new("/repo"), small, small * 16).unwrap();
425        assert_eq!(f.dir, "src/relay");
426        let _ = std::fs::remove_file(&tmp);
427    }
428
429    #[test]
430    fn a_window_full_of_one_huge_tool_result_grows_until_it_finds_the_edits() {
431        let tmp = std::env::temp_dir().join(format!("focus-grow-{}", std::process::id()));
432        let edit = r#"{"type":"assistant","message":{"role":"assistant","content":[{"type":"tool_use","name":"Edit","input":{"file_path":"/repo/src/relay/peer.rs"}}]}}"#;
433        let log = "x".repeat(4096);
434        let result = format!(
435            r#"{{"type":"user","message":{{"role":"user","content":[{{"type":"tool_result","content":"{log}"}}]}}}}"#
436        );
437        std::fs::write(&tmp, format!("{edit}\n{result}\n")).unwrap();
438        // A window the size of the result alone sees no edit...
439        let small = (result.len() + 1) as u64;
440        assert_eq!(edits_from_transcript(&tmp, small).len(), 0);
441        // ...so the focus read grows and finds it, within the bound.
442        let f = session_focus_within(&tmp, Path::new("/repo"), small, small * 16).unwrap();
443        assert_eq!(f.dir, "src/relay");
444        // With the bound at the small window it honestly reports None.
445        assert_eq!(
446            session_focus_within(&tmp, Path::new("/repo"), small, small),
447            None
448        );
449        let _ = std::fs::remove_file(&tmp);
450    }
451
452    #[test]
453    fn a_missing_transcript_is_no_focus() {
454        assert_eq!(session_focus(None, Path::new("/repo")), None);
455        assert_eq!(
456            session_focus(Some(Path::new("/nonexistent/x.jsonl")), Path::new("/repo")),
457            None
458        );
459    }
460}