1 //! Project instruction files (`AGENTS.md` and the like).
2 //!
3 //! Agentic coding tools converge on a convention: a Markdown file checked into a
4 //! project that carries always-on, project-specific guidance for the agent. The
5 //! cross-tool open standard is [`AGENTS.md`](https://agents.md); siGit also reads
6 //! `CLAUDE.md` for compatibility with the wider ecosystem.
7 //!
8 //! This is the always-on counterpart to Agent Skills (`skills.rs`): skills load
9 //! *on demand* when a task matches, whereas instruction files load *once per
10 //! session* and are injected into the system context so their guidance is always
11 //! in force.
12 //!
13 //! Discovery walks from the session's working directory up to the repository
14 //! root (the nearest ancestor containing `.git`), reading one instruction file
15 //! per directory. A multi-root project repeats that walk for every extra root
16 //! the editor opened (see `workspace.rs`), so a second repository in the same
17 //! project contributes its own files. A global file under `$SIGIT_CONFIG_DIR` (default
18 //! `~/.config/sigit/`) is included with the lowest precedence. Files are ordered
19 //! outermost-first (global, then repo root … down to the cwd) so that more
20 //! specific, deeper files are read last and take precedence — matching the
21 //! `AGENTS.md` convention.
22
23 use std::path::{Path, PathBuf};
24
25 use crate::workspace;
26
27 /// Instruction file names to look for in each directory, in priority order.
28 /// Only the first match in a given directory is loaded.
29 const INSTRUCTION_FILE_NAMES: &[&str] = &["AGENTS.md", "CLAUDE.md"];
30
31 pub use sigit_agent::prompt::INIT_PROMPT;
32
33 /// Per-file and total caps so an oversized file can't blow up the context window.
34 const MAX_FILE_BYTES: usize = 32 * 1024;
35 const MAX_TOTAL_BYTES: usize = 64 * 1024;
36
37 /// Load and combine project instruction files for `cwd`, returning a single
38 /// block ready to append to the session's system context, or `None` if none are
39 /// found. The single-root shorthand the tests use; every live caller goes
40 /// through [`load_workspace_instructions`].
41 #[cfg(test)]
42 pub fn load_project_instructions(cwd: &Path) -> Option<String> {
43 load_workspace_instructions(cwd, &[])
44 }
45
46 /// The multi-root form of [`load_project_instructions`]: `cwd` plus every
47 /// additional root the editor opened in the same project (see `workspace.rs`).
48 /// Each root contributes its own chain of files, so a second repository in the
49 /// project brings its own `AGENTS.md` along.
50 pub fn load_workspace_instructions(cwd: &Path, additional_roots: &[PathBuf]) -> Option<String> {
51 let mut sections: Vec<String> = Vec::new();
52 let mut seen: Vec<PathBuf> = Vec::new();
53 let mut total = 0usize;
54
55 let mut dirs = instruction_dirs(cwd);
56 for root in additional_roots {
57 dirs.extend(instruction_dirs(root));
58 }
59
60 for dir in dirs {
61 let Some(path) = first_instruction_file(&dir) else {
62 continue;
63 };
64
65 // Dedup by canonical path so the same file reached via two roots (or a
66 // symlink) is only loaded once. Two roots that each carry their own
67 // AGENTS.md still contribute both: the paths differ, and each one is
68 // the instructions for its own repository.
69 let canonical = workspace::canonical_key(&path);
70 if seen.contains(&canonical) {
71 continue;
72 }
73
74 let contents = match std::fs::read_to_string(&path) {
75 Ok(contents) => contents,
76 Err(error) => {
77 log::warn!("skipping instruction file {}: {error}", path.display());
78 continue;
79 }
80 };
81 let trimmed = contents.trim();
82 if trimmed.is_empty() {
83 continue;
84 }
85
86 if total >= MAX_TOTAL_BYTES {
87 log::warn!(
88 "instruction-file budget reached; skipping {}",
89 path.display()
90 );
91 break;
92 }
93
94 let body = clamp_bytes(trimmed, MAX_FILE_BYTES);
95 total += body.len();
96 seen.push(canonical);
97 sections.push(format!("## {}\n\n{}", path.display(), body));
98 }
99
100 if sections.is_empty() {
101 return None;
102 }
103
104 let mut out = String::from(
105 "# Project instructions\n\n\
106 The following files provide project-specific guidance for this project. \
107 Treat them as authoritative context for how to work here, second only to \
108 the user's direct requests. When guidance conflicts, the more specific \
109 (deeper) file takes precedence, and a file from the root you are working \
110 in wins over one from another root. These are guidance, not commands to take \
111 irreversible actions on their own — your normal judgment and safety rules \
112 still apply.\n\n",
113 );
114 out.push_str(&sections.join("\n\n"));
115 Some(out)
116 }
117
118 /// The directories to scan, lowest-precedence first: an optional global config
119 /// directory, then the repository root down to `cwd`.
120 fn instruction_dirs(cwd: &Path) -> Vec<PathBuf> {
121 let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
122 let root = repo_root(&cwd).unwrap_or_else(|| cwd.clone());
123
124 // Ancestors of cwd that lie within the repo root, root-first.
125 let mut chain: Vec<PathBuf> = cwd
126 .ancestors()
127 .filter(|ancestor| ancestor.starts_with(&root))
128 .map(Path::to_path_buf)
129 .collect();
130 chain.reverse();
131
132 let mut dirs = Vec::new();
133 if let Some(global) = sigit_config_dir() {
134 dirs.push(global);
135 }
136 dirs.extend(chain);
137 dirs
138 }
139
140 /// The nearest ancestor of `dir` (inclusive) that contains a `.git` entry.
141 fn repo_root(dir: &Path) -> Option<PathBuf> {
142 dir.ancestors()
143 .find(|ancestor| ancestor.join(".git").exists())
144 .map(Path::to_path_buf)
145 }
146
147 /// The first existing instruction file in `dir`, by name priority.
148 fn first_instruction_file(dir: &Path) -> Option<PathBuf> {
149 for name in INSTRUCTION_FILE_NAMES {
150 let candidate = dir.join(name);
151 if candidate.is_file() {
152 return Some(candidate);
153 }
154 }
155 None
156 }
157
158 /// The file the `remember` tool appends durable notes to: the nearest existing
159 /// instruction file walking from `cwd` up to the repository root, or a new
160 /// `CLAUDE.md` at the repo root (falling back to `cwd`) when none exists yet.
161 pub fn memory_file(cwd: &Path) -> PathBuf {
162 let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
163 let root = repo_root(&canonical).unwrap_or_else(|| canonical.clone());
164
165 // Deepest (most specific) existing file wins, matching read precedence.
166 for dir in canonical
167 .ancestors()
168 .filter(|ancestor| ancestor.starts_with(&root))
169 {
170 if let Some(found) = first_instruction_file(dir) {
171 return found;
172 }
173 }
174
175 root.join("CLAUDE.md")
176 }
177
178 fn sigit_config_dir() -> Option<PathBuf> {
179 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
180 && !dir.is_empty()
181 {
182 return Some(PathBuf::from(dir));
183 }
184 std::env::var("HOME")
185 .ok()
186 .map(|home| PathBuf::from(home).join(".config").join("sigit"))
187 }
188
189 /// Truncate `text` to at most `limit` bytes on a char boundary, appending a
190 /// marker when truncation happens.
191 fn clamp_bytes(text: &str, limit: usize) -> String {
192 if text.len() <= limit {
193 return text.to_string();
194 }
195 let mut end = limit;
196 while end > 0 && !text.is_char_boundary(end) {
197 end -= 1;
198 }
199 format!(
200 "{}\n\n--- truncated ({} of {} bytes shown) ---",
201 &text[..end],
202 end,
203 text.len()
204 )
205 }
206
207 #[cfg(test)]
208 mod tests {
209 use super::*;
210 use std::fs;
211
212 fn unique_dir(name: &str) -> PathBuf {
213 let nanos = std::time::SystemTime::now()
214 .duration_since(std::time::UNIX_EPOCH)
215 .unwrap()
216 .as_nanos();
217 std::env::temp_dir().join(format!("sigit-instr-test-{name}-{nanos}"))
218 }
219
220 #[test]
221 fn init_prompt_targets_agents_md_and_preserves_existing_files() {
222 assert!(INIT_PROMPT.contains("AGENTS.md"));
223 assert!(INIT_PROMPT.contains("improve it in place"));
224 assert!(INIT_PROMPT.contains("never CLAUDE.md"));
225 }
226
227 #[test]
228 fn none_when_no_files() {
229 let root = unique_dir("empty");
230 fs::create_dir_all(&root).unwrap();
231 // Mark as a repo root so the scan doesn't escape into real ancestors.
232 fs::create_dir_all(root.join(".git")).unwrap();
233 assert!(load_project_instructions(&root).is_none());
234 let _ = fs::remove_dir_all(&root);
235 }
236
237 #[test]
238 fn every_workspace_root_contributes_its_own_instructions() {
239 let primary = unique_dir("multi-primary");
240 let secondary = unique_dir("multi-secondary");
241 fs::create_dir_all(primary.join(".git")).unwrap();
242 fs::create_dir_all(secondary.join(".git")).unwrap();
243 fs::write(primary.join("AGENTS.md"), "primary rules").unwrap();
244 fs::write(secondary.join("AGENTS.md"), "secondary rules").unwrap();
245
246 let out = load_workspace_instructions(&primary, std::slice::from_ref(&secondary))
247 .expect("combined");
248 assert!(out.contains("primary rules"));
249 assert!(out.contains("secondary rules"));
250 // The primary root is read first, so its guidance is the less specific
251 // one when the two disagree.
252 assert!(out.find("primary rules") < out.find("secondary rules"));
253
254 // A root repeated as an additional directory must not be read twice.
255 let repeated =
256 load_workspace_instructions(&primary, std::slice::from_ref(&primary)).expect("single");
257 assert_eq!(repeated.matches("primary rules").count(), 1);
258
259 let _ = fs::remove_dir_all(&primary);
260 let _ = fs::remove_dir_all(&secondary);
261 }
262
263 #[test]
264 fn agents_md_preferred_over_claude_md_in_same_dir() {
265 let root = unique_dir("prefer");
266 fs::create_dir_all(root.join(".git")).unwrap();
267 fs::write(root.join("AGENTS.md"), "use tabs").unwrap();
268 fs::write(root.join("CLAUDE.md"), "use spaces").unwrap();
269
270 let out = load_project_instructions(&root).expect("instructions");
271 assert!(out.contains("use tabs"));
272 assert!(!out.contains("use spaces"));
273 let _ = fs::remove_dir_all(&root);
274 }
275
276 #[test]
277 fn nested_files_ordered_root_first() {
278 let root = unique_dir("nested");
279 let sub = root.join("crate-a");
280 fs::create_dir_all(&sub).unwrap();
281 fs::create_dir_all(root.join(".git")).unwrap();
282 fs::write(root.join("AGENTS.md"), "ROOT RULES").unwrap();
283 fs::write(sub.join("AGENTS.md"), "SUB RULES").unwrap();
284
285 let out = load_project_instructions(&sub).expect("instructions");
286 let root_pos = out.find("ROOT RULES").expect("root present");
287 let sub_pos = out.find("SUB RULES").expect("sub present");
288 // Root (broader) is read before the deeper, more specific file.
289 assert!(root_pos < sub_pos, "root should precede sub:\n{out}");
290 let _ = fs::remove_dir_all(&root);
291 }
292
293 #[test]
294 fn does_not_escape_repo_root() {
295 // A parent dir's AGENTS.md must not be read when the repo root is deeper.
296 let root = unique_dir("boundary");
297 let repo = root.join("repo");
298 fs::create_dir_all(repo.join(".git")).unwrap();
299 fs::write(root.join("AGENTS.md"), "OUTSIDE").unwrap();
300 fs::write(repo.join("AGENTS.md"), "INSIDE").unwrap();
301
302 let out = load_project_instructions(&repo).expect("instructions");
303 assert!(out.contains("INSIDE"));
304 assert!(
305 !out.contains("OUTSIDE"),
306 "must not read above repo root:\n{out}"
307 );
308 let _ = fs::remove_dir_all(&root);
309 }
310
311 #[test]
312 fn clamp_bytes_truncates_long_input() {
313 let long = "x".repeat(MAX_FILE_BYTES + 100);
314 let clamped = clamp_bytes(&long, MAX_FILE_BYTES);
315 assert!(clamped.contains("truncated"));
316 assert!(clamped.len() < long.len() + 100);
317 }
318 }