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
// SPDX-License-Identifier: Apache-2.0
//! Instruction-file and user-exclusion predicates shared by `ctx_read`, the
//! automatic mode resolver and the hook redirect. Moved down from
//! `tools/ctx_read/helpers.rs` so engine callers do not depend on the tools layer.
/// Document extensions that carry instructions. Anything else beneath a skill
/// or rules directory is implementation, not instruction (#1794).
///
/// An extensionless file counts as a document: rule files are routinely named
/// without one (`.cursorrules`, `PROMPT`), and no language ships source that way.
const INSTRUCTION_DOC_EXTENSIONS: &[&str] = &["md", "mdc", "markdown", "txt", "rst", "adoc"];
/// Whether `path` is an instruction document that must always be read complete:
/// a built-in agent instruction file, or a path the user listed in
/// `redirect_exclude` / `LEAN_CTX_HOOK_EXCLUDE` (see [`is_user_excluded`]).
///
/// Matching is by *file*, never by ancestor directory alone (#1794). A skill
/// ships its instructions as documents and its implementation as source, so a
/// `.ts`/`.py`/`.rs` file under `skills/` is ordinary code: forcing it to
/// `full` turned a bounded structural request into a large truncated dump —
/// the caller lost the map it asked for *and* the tail of the file.
pub fn is_instruction_file(path: &str) -> bool {
is_builtin_instruction_file(path) || is_user_excluded(path)
}
fn is_builtin_instruction_file(path: &str) -> bool {
let lower = path.to_lowercase().replace('\\', "/");
let file = std::path::Path::new(&lower);
let filename = file.file_name().and_then(|f| f.to_str()).unwrap_or("");
// Instruction documents by name, wherever they live: the files each agent
// loads as standing instructions (Claude Code, Gemini CLI, Copilot,
// Windsurf, Cursor, Cline) plus the generic names.
if matches!(
filename,
"skill.md"
| "agents.md"
| "agents.override.md"
| "claude.md"
| "claude.local.md"
| "gemini.md"
| "copilot-instructions.md"
| "rules.md"
| ".cursorrules"
| ".clinerules"
| ".windsurfrules"
| "lean-ctx.md"
| "lean-ctx.mdc"
) || filename.ends_with(".instructions.md")
{
return true;
}
// Inside an instruction directory, only documents qualify. A relative
// path can start with the directory (`skills/x/SKILL.md`), so match on a
// leading `/` added to the path rather than on the raw string.
let rooted = format!("/{}", lower.trim_start_matches('/'));
let in_instruction_dir = [
"/skills/",
"/.cursor/rules/",
"/.claude/rules/",
"/.claude/agents/",
"/.claude/commands/",
"/.clinerules/",
"/.windsurf/rules/",
"/.github/instructions/",
]
.iter()
.any(|dir| rooted.contains(dir));
in_instruction_dir
&& file
.extension()
.and_then(|e| e.to_str())
.is_none_or(|ext| INSTRUCTION_DOC_EXTENSIONS.contains(&ext))
}
/// Whether a read of `path` in `mode` must be served as `full` because the
/// file carries agent instructions: every agent needs its complete text, so a
/// compressed view is re-read in full right away (a guaranteed bounce).
/// Lossless and pinned views — `full`, `raw`, `anchored`, `diff`, `lines:` —
/// keep the requested mode: they never withhold content the caller asked for.
pub fn instruction_file_forces_full(path: &str, mode: &str) -> bool {
!(matches!(mode, "full" | "raw" | "anchored" | "diff")
|| mode.starts_with("anchored:")
|| mode.starts_with("lines:"))
&& is_instruction_file(path)
}
/// Whether the user excluded `path` from lean-ctx rewriting: `redirect_exclude`
/// globs in config, or `LEAN_CTX_HOOK_EXCLUDE` (comma-separated, takes
/// precedence). Native reads of such paths skip the hook redirect, and
/// automatic `ctx_read` modes deliver them in full.
pub fn is_user_excluded(path: &str) -> bool {
let env = std::env::var("LEAN_CTX_HOOK_EXCLUDE").ok();
excluded_by(path, env.as_deref(), || {
crate::core::config::Config::load_arc()
.redirect_exclude
.clone()
})
}
/// [`is_user_excluded`] with its inputs explicit: a non-empty env list wins
/// over the configured globs.
pub fn excluded_by(
path: &str,
env: Option<&str>,
configured: impl FnOnce() -> Vec<String>,
) -> bool {
match env {
Some(list) if !list.trim().is_empty() => {
let patterns: Vec<&str> = list.split(',').collect();
path_matches_any(path, &patterns)
}
_ => {
let configured = configured();
let patterns: Vec<&str> = configured.iter().map(String::as_str).collect();
path_matches_any(path, &patterns)
}
}
}
/// Glob match against the path's trailing components: `CLAUDE.md` and
/// `*.json` match a file name anywhere, `docs/*.md` or `.claude/**` match that
/// directory wherever it sits. `*`/`?` stay within one component, `**` spans
/// several. Case-insensitive where the file system usually is.
fn path_matches_any(path: &str, patterns: &[&str]) -> bool {
let normalized = path.replace('\\', "/");
let components: Vec<&str> = normalized.split('/').filter(|c| !c.is_empty()).collect();
let options = glob::MatchOptions {
case_sensitive: !cfg!(any(windows, target_os = "macos")),
require_literal_separator: true,
require_literal_leading_dot: false,
};
patterns
.iter()
.map(|p| p.trim().trim_start_matches("./"))
.filter(|p| !p.is_empty())
.filter_map(|p| glob::Pattern::new(p).ok())
.any(|pattern| {
(0..components.len())
.any(|start| pattern.matches_with(&components[start..].join("/"), options))
})
}