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 /// The prompt behind the `/init` slash command. Both the TUI and the ACP
32 /// server substitute it for the user's input and run a normal agent turn, so
33 /// generation goes through the ordinary tools and permission checks. It lives
34 /// here, next to the loader that consumes the file it produces.
35 pub const INIT_PROMPT: &str = "\
36 Analyze this repository and write an AGENTS.md instruction file for AI coding \
37 agents working in it.
38
39 First explore, then write. Read the manifest/build files (Cargo.toml, \
40 package.json, pyproject.toml, Makefile, or equivalents), the CI configuration, \
41 the README, and the top one or two levels of the directory tree. Read broadly \
42 but shallowly; do not descend into every subdirectory.
43
44 Then create AGENTS.md at the repository root covering, briefly:
45 - what the project is and does (a sentence or two)
46 - how to build, test, and lint it (the exact commands)
47 - the architecture: main modules/directories and what each owns
48 - conventions an agent must follow (branch naming, commit rules, code style, \
49 platform constraints) that the repository itself shows evidence of
50
51 Keep it compact — aim for under 60 lines. State only what this repository \
52 actually supports; no generic boilerplate.
53
54 If AGENTS.md or CLAUDE.md already exists, read it first and improve it in \
55 place (fix what is stale, add what is missing) instead of replacing it. \
56 Always target AGENTS.md, never CLAUDE.md.";
57
58 /// Per-file and total caps so an oversized file can't blow up the context window.
59 const MAX_FILE_BYTES: usize = 32 * 1024;
60 const MAX_TOTAL_BYTES: usize = 64 * 1024;
61
62 /// Load and combine project instruction files for `cwd`, returning a single
63 /// block ready to append to the session's system context, or `None` if none are
64 /// found. The single-root shorthand the tests use; every live caller goes
65 /// through [`load_workspace_instructions`].
66 #[cfg(test)]
67 pub fn load_project_instructions(cwd: &Path) -> Option<String> {
68 load_workspace_instructions(cwd, &[])
69 }
70
71 /// The multi-root form of [`load_project_instructions`]: `cwd` plus every
72 /// additional root the editor opened in the same project (see `workspace.rs`).
73 /// Each root contributes its own chain of files, so a second repository in the
74 /// project brings its own `AGENTS.md` along.
75 pub fn load_workspace_instructions(cwd: &Path, additional_roots: &[PathBuf]) -> Option<String> {
76 let mut sections: Vec<String> = Vec::new();
77 let mut seen: Vec<PathBuf> = Vec::new();
78 let mut total = 0usize;
79
80 let mut dirs = instruction_dirs(cwd);
81 for root in additional_roots {
82 dirs.extend(instruction_dirs(root));
83 }
84
85 for dir in dirs {
86 let Some(path) = first_instruction_file(&dir) else {
87 continue;
88 };
89
90 // Dedup by canonical path so the same file reached via two roots (or a
91 // symlink) is only loaded once. Two roots that each carry their own
92 // AGENTS.md still contribute both: the paths differ, and each one is
93 // the instructions for its own repository.
94 let canonical = workspace::canonical_key(&path);
95 if seen.contains(&canonical) {
96 continue;
97 }
98
99 let contents = match std::fs::read_to_string(&path) {
100 Ok(contents) => contents,
101 Err(error) => {
102 log::warn!("skipping instruction file {}: {error}", path.display());
103 continue;
104 }
105 };
106 let trimmed = contents.trim();
107 if trimmed.is_empty() {
108 continue;
109 }
110
111 if total >= MAX_TOTAL_BYTES {
112 log::warn!(
113 "instruction-file budget reached; skipping {}",
114 path.display()
115 );
116 break;
117 }
118
119 let body = clamp_bytes(trimmed, MAX_FILE_BYTES);
120 total += body.len();
121 seen.push(canonical);
122 sections.push(format!("## {}\n\n{}", path.display(), body));
123 }
124
125 if sections.is_empty() {
126 return None;
127 }
128
129 let mut out = String::from(
130 "# Project instructions\n\n\
131 The following files provide project-specific guidance for this project. \
132 Treat them as authoritative context for how to work here, second only to \
133 the user's direct requests. When guidance conflicts, the more specific \
134 (deeper) file takes precedence, and a file from the root you are working \
135 in wins over one from another root. These are guidance, not commands to take \
136 irreversible actions on their own — your normal judgment and safety rules \
137 still apply.\n\n",
138 );
139 out.push_str(&sections.join("\n\n"));
140 Some(out)
141 }
142
143 /// The directories to scan, lowest-precedence first: an optional global config
144 /// directory, then the repository root down to `cwd`.
145 fn instruction_dirs(cwd: &Path) -> Vec<PathBuf> {
146 let cwd = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
147 let root = repo_root(&cwd).unwrap_or_else(|| cwd.clone());
148
149 // Ancestors of cwd that lie within the repo root, root-first.
150 let mut chain: Vec<PathBuf> = cwd
151 .ancestors()
152 .filter(|ancestor| ancestor.starts_with(&root))
153 .map(Path::to_path_buf)
154 .collect();
155 chain.reverse();
156
157 let mut dirs = Vec::new();
158 if let Some(global) = sigit_config_dir() {
159 dirs.push(global);
160 }
161 dirs.extend(chain);
162 dirs
163 }
164
165 /// The nearest ancestor of `dir` (inclusive) that contains a `.git` entry.
166 fn repo_root(dir: &Path) -> Option<PathBuf> {
167 dir.ancestors()
168 .find(|ancestor| ancestor.join(".git").exists())
169 .map(Path::to_path_buf)
170 }
171
172 /// The first existing instruction file in `dir`, by name priority.
173 fn first_instruction_file(dir: &Path) -> Option<PathBuf> {
174 for name in INSTRUCTION_FILE_NAMES {
175 let candidate = dir.join(name);
176 if candidate.is_file() {
177 return Some(candidate);
178 }
179 }
180 None
181 }
182
183 /// The file the `remember` tool appends durable notes to: the nearest existing
184 /// instruction file walking from `cwd` up to the repository root, or a new
185 /// `CLAUDE.md` at the repo root (falling back to `cwd`) when none exists yet.
186 pub fn memory_file(cwd: &Path) -> PathBuf {
187 let canonical = cwd.canonicalize().unwrap_or_else(|_| cwd.to_path_buf());
188 let root = repo_root(&canonical).unwrap_or_else(|| canonical.clone());
189
190 // Deepest (most specific) existing file wins, matching read precedence.
191 for dir in canonical
192 .ancestors()
193 .filter(|ancestor| ancestor.starts_with(&root))
194 {
195 if let Some(found) = first_instruction_file(dir) {
196 return found;
197 }
198 }
199
200 root.join("CLAUDE.md")
201 }
202
203 fn sigit_config_dir() -> Option<PathBuf> {
204 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR")
205 && !dir.is_empty()
206 {
207 return Some(PathBuf::from(dir));
208 }
209 std::env::var("HOME")
210 .ok()
211 .map(|home| PathBuf::from(home).join(".config").join("sigit"))
212 }
213
214 /// Truncate `text` to at most `limit` bytes on a char boundary, appending a
215 /// marker when truncation happens.
216 fn clamp_bytes(text: &str, limit: usize) -> String {
217 if text.len() <= limit {
218 return text.to_string();
219 }
220 let mut end = limit;
221 while end > 0 && !text.is_char_boundary(end) {
222 end -= 1;
223 }
224 format!(
225 "{}\n\n--- truncated ({} of {} bytes shown) ---",
226 &text[..end],
227 end,
228 text.len()
229 )
230 }
231
232 #[cfg(test)]
233 mod tests {
234 use super::*;
235 use std::fs;
236
237 fn unique_dir(name: &str) -> PathBuf {
238 let nanos = std::time::SystemTime::now()
239 .duration_since(std::time::UNIX_EPOCH)
240 .unwrap()
241 .as_nanos();
242 std::env::temp_dir().join(format!("sigit-instr-test-{name}-{nanos}"))
243 }
244
245 #[test]
246 fn init_prompt_targets_agents_md_and_preserves_existing_files() {
247 assert!(INIT_PROMPT.contains("AGENTS.md"));
248 assert!(INIT_PROMPT.contains("improve it in place"));
249 assert!(INIT_PROMPT.contains("never CLAUDE.md"));
250 }
251
252 #[test]
253 fn none_when_no_files() {
254 let root = unique_dir("empty");
255 fs::create_dir_all(&root).unwrap();
256 // Mark as a repo root so the scan doesn't escape into real ancestors.
257 fs::create_dir_all(root.join(".git")).unwrap();
258 assert!(load_project_instructions(&root).is_none());
259 let _ = fs::remove_dir_all(&root);
260 }
261
262 #[test]
263 fn every_workspace_root_contributes_its_own_instructions() {
264 let primary = unique_dir("multi-primary");
265 let secondary = unique_dir("multi-secondary");
266 fs::create_dir_all(primary.join(".git")).unwrap();
267 fs::create_dir_all(secondary.join(".git")).unwrap();
268 fs::write(primary.join("AGENTS.md"), "primary rules").unwrap();
269 fs::write(secondary.join("AGENTS.md"), "secondary rules").unwrap();
270
271 let out = load_workspace_instructions(&primary, std::slice::from_ref(&secondary))
272 .expect("combined");
273 assert!(out.contains("primary rules"));
274 assert!(out.contains("secondary rules"));
275 // The primary root is read first, so its guidance is the less specific
276 // one when the two disagree.
277 assert!(out.find("primary rules") < out.find("secondary rules"));
278
279 // A root repeated as an additional directory must not be read twice.
280 let repeated =
281 load_workspace_instructions(&primary, std::slice::from_ref(&primary)).expect("single");
282 assert_eq!(repeated.matches("primary rules").count(), 1);
283
284 let _ = fs::remove_dir_all(&primary);
285 let _ = fs::remove_dir_all(&secondary);
286 }
287
288 #[test]
289 fn agents_md_preferred_over_claude_md_in_same_dir() {
290 let root = unique_dir("prefer");
291 fs::create_dir_all(root.join(".git")).unwrap();
292 fs::write(root.join("AGENTS.md"), "use tabs").unwrap();
293 fs::write(root.join("CLAUDE.md"), "use spaces").unwrap();
294
295 let out = load_project_instructions(&root).expect("instructions");
296 assert!(out.contains("use tabs"));
297 assert!(!out.contains("use spaces"));
298 let _ = fs::remove_dir_all(&root);
299 }
300
301 #[test]
302 fn nested_files_ordered_root_first() {
303 let root = unique_dir("nested");
304 let sub = root.join("crate-a");
305 fs::create_dir_all(&sub).unwrap();
306 fs::create_dir_all(root.join(".git")).unwrap();
307 fs::write(root.join("AGENTS.md"), "ROOT RULES").unwrap();
308 fs::write(sub.join("AGENTS.md"), "SUB RULES").unwrap();
309
310 let out = load_project_instructions(&sub).expect("instructions");
311 let root_pos = out.find("ROOT RULES").expect("root present");
312 let sub_pos = out.find("SUB RULES").expect("sub present");
313 // Root (broader) is read before the deeper, more specific file.
314 assert!(root_pos < sub_pos, "root should precede sub:\n{out}");
315 let _ = fs::remove_dir_all(&root);
316 }
317
318 #[test]
319 fn does_not_escape_repo_root() {
320 // A parent dir's AGENTS.md must not be read when the repo root is deeper.
321 let root = unique_dir("boundary");
322 let repo = root.join("repo");
323 fs::create_dir_all(repo.join(".git")).unwrap();
324 fs::write(root.join("AGENTS.md"), "OUTSIDE").unwrap();
325 fs::write(repo.join("AGENTS.md"), "INSIDE").unwrap();
326
327 let out = load_project_instructions(&repo).expect("instructions");
328 assert!(out.contains("INSIDE"));
329 assert!(
330 !out.contains("OUTSIDE"),
331 "must not read above repo root:\n{out}"
332 );
333 let _ = fs::remove_dir_all(&root);
334 }
335
336 #[test]
337 fn clamp_bytes_truncates_long_input() {
338 let long = "x".repeat(MAX_FILE_BYTES + 100);
339 let clamped = clamp_bytes(&long, MAX_FILE_BYTES);
340 assert!(clamped.contains("truncated"));
341 assert!(clamped.len() < long.len() + 100);
342 }
343 }