Skip to main content

supercode_harness/
path_rules.rs

1//! BP-5 (catalog D2 "Path-scoped rules": *rule files activated only when
2//! matching files are touched*; cc§2 "`.claude/rules/*.md` — modular
3//! instruction files; optional `paths:` frontmatter scopes a rule to file
4//! globs so it loads only when Claude touches matching files").
5//!
6//! **A prompt-assembly input, not a module.** A rule file is an instruction
7//! file that happens to carry a selector. There are exactly two ways it can
8//! reach a prompt, and both are doors that already existed:
9//!
10//! * **No `paths:`** — it joins the instruction blob at construction, beside
11//!   `CLAUDE.md`/`AGENTS.md`, under the same `core.project_context` byte
12//!   budget (`agent::assemble_project_instructions`).
13//! * **With `paths:`** — it is held back, and injected as a tool-result
14//!   notice the first time a tool touches a matching file. That is the exact
15//!   mechanism `core.nested_instructions` already uses for a subdirectory's
16//!   own CLAUDE.md (`tools::builtins::nested_instructions_notice`), with the
17//!   selector swapped from "the directory you touched" to "a glob this rule
18//!   declares". Each rule is injected at most once per session, deduped by
19//!   path, exactly as nested instructions are.
20//!
21//! **Roots.** `<CLAUDE_CONFIG_DIR>/rules/` (the user tier) and
22//! `.claude/rules/` in each directory of the instruction walk
23//! (`docs:memory#organize-rules-with-claude-rules`). Read only when
24//! `[core.path_rules]` is on; a config that does not set it opens no
25//! directory at all.
26
27use std::path::{Path, PathBuf};
28
29use crate::config::Config;
30
31/// Ceiling on the bytes of one rule file that reach a prompt.
32const MAX_RULE_BYTES: usize = 32 * 1024;
33
34/// Ceiling on the number of rule files one config may load, so a rules
35/// directory cannot make agent construction unbounded.
36const MAX_RULE_FILES: usize = 64;
37
38/// One `.claude/rules/*.md` file.
39#[derive(Debug, Clone, PartialEq, Eq)]
40pub struct RuleFile {
41    /// Frontmatter `name`, else the file stem.
42    pub name: String,
43    /// The file itself.
44    pub path: PathBuf,
45    /// `paths:` frontmatter globs. Empty = unscoped (always loaded).
46    pub paths: Vec<String>,
47    /// Everything after the frontmatter, trimmed and capped.
48    pub body: String,
49}
50
51impl RuleFile {
52    /// Whether this rule waits for a matching file to be touched.
53    pub fn is_scoped(&self) -> bool {
54        !self.paths.is_empty()
55    }
56
57    /// Does this rule's selector match `touched`? Each glob is tried
58    /// against the path relative to `root`, the full path, and the bare
59    /// file name — the same three spellings instruction-file excludes are
60    /// matched in (`agent::instruction_file_excluded`), so one glob
61    /// spelling means one thing across the product.
62    pub fn matches(&self, touched: &Path, root: &Path) -> bool {
63        let full = touched.to_string_lossy().to_string();
64        let name = touched
65            .file_name()
66            .map(|n| n.to_string_lossy().to_string())
67            .unwrap_or_default();
68        let rel = touched
69            .strip_prefix(root)
70            .ok()
71            .map(|p| p.to_string_lossy().to_string());
72        self.paths.iter().any(|pattern| {
73            // gitignore-spec `**/` matches ZERO OR MORE directories, so
74            // `src/**/*.rs` covers `src/lib.rs` as well as `src/a/b.rs`.
75            // The product's `*` matcher has no `**` concept (its `*` already
76            // crosses `/`), so the zero-directory reading is supplied here as
77            // a second candidate spelling rather than by a second matcher.
78            let collapsed = pattern.replace("/**/", "/");
79            let mut candidates = vec![pattern.as_str()];
80            if collapsed != *pattern {
81                candidates.push(collapsed.as_str());
82            }
83            candidates.iter().any(|pattern| {
84                crate::config::glob_match(pattern, &full)
85                    || crate::config::glob_match(pattern, &name)
86                    || rel
87                        .as_deref()
88                        .is_some_and(|r| crate::config::glob_match(pattern, r))
89            })
90        })
91    }
92
93    /// How this rule renders wherever it is injected — one shape, so a rule
94    /// read at startup and the same rule injected on a tool result are
95    /// recognizably the same thing.
96    pub fn render(&self) -> String {
97        format!("[rule: {}]\n{}", self.name, self.body)
98    }
99}
100
101/// Every rule file `config` loads, user tier first, then the instruction
102/// walk from the outermost root down to `cwd` — the same root→cwd ordering
103/// instruction files use, so the nearest rule is read last. Empty (and free
104/// of any filesystem work) when `[core.path_rules]` is off.
105pub fn load(config: &Config) -> Vec<RuleFile> {
106    if !config.path_rules {
107        return Vec::new();
108    }
109    let mut out = Vec::new();
110    let mut seen: std::collections::BTreeSet<PathBuf> = std::collections::BTreeSet::new();
111    for root in rule_roots(config) {
112        let Ok(entries) = std::fs::read_dir(&root) else {
113            continue;
114        };
115        let mut files: Vec<PathBuf> = entries
116            .flatten()
117            .map(|e| e.path())
118            .filter(|p| p.extension().and_then(|e| e.to_str()) == Some("md"))
119            .collect();
120        files.sort();
121        for file in files {
122            if out.len() >= MAX_RULE_FILES {
123                return out;
124            }
125            let canonical = std::fs::canonicalize(&file).unwrap_or_else(|_| file.clone());
126            if !seen.insert(canonical) {
127                continue;
128            }
129            if let Some(rule) = read_rule(&file) {
130                out.push(rule);
131            }
132        }
133    }
134    out
135}
136
137/// The rule directories, in load order.
138fn rule_roots(config: &Config) -> Vec<PathBuf> {
139    let mut roots = vec![crate::skills::SkillHomes::default()
140        .claude_code
141        .join("rules")];
142    for dir in crate::agent::instruction_walk_roots(config) {
143        roots.push(dir.join(".claude").join("rules"));
144    }
145    roots
146}
147
148/// Parse one rule file. `None` when it has no body worth injecting.
149fn read_rule(path: &Path) -> Option<RuleFile> {
150    let text = std::fs::read_to_string(path).ok()?;
151    let front = crate::skills::read_frontmatter(path);
152    let mut body = crate::skills::strip_frontmatter(&text);
153    if body.is_empty() {
154        return None;
155    }
156    if body.len() > MAX_RULE_BYTES {
157        let mut cut = MAX_RULE_BYTES;
158        while cut > 0 && !body.is_char_boundary(cut) {
159            cut -= 1;
160        }
161        body.truncate(cut);
162        body.push_str("\n[rule truncated]");
163    }
164    let name = front.get("name").cloned().unwrap_or_else(|| {
165        path.file_stem()
166            .map(|s| s.to_string_lossy().to_string())
167            .unwrap_or_default()
168    });
169    Some(RuleFile {
170        name,
171        path: path.to_path_buf(),
172        paths: front
173            .get("paths")
174            .map(|v| crate::skills::frontmatter_list(v))
175            .unwrap_or_default(),
176        body,
177    })
178}
179
180/// The unscoped rules' contribution to the instruction blob, in load order.
181/// Empty when nothing is unscoped.
182pub fn always_on_text(rules: &[RuleFile]) -> String {
183    let mut out = String::new();
184    for rule in rules.iter().filter(|r| !r.is_scoped()) {
185        out.push_str("\n\n");
186        out.push_str(&rule.render());
187    }
188    out
189}