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