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