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