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}