Skip to main content

car_server_core/coder/
project_context.rs

1//! What the repository itself says about how to work in it.
2//!
3//! Until this existed, everything a coder session knew about the repo it was
4//! pointed at was two lines from `summarize_repo` — the top-level entries and
5//! which build files exist. Three separate bodies of project knowledge sat on
6//! disk and none of them reached the loop (car#1071).
7//!
8//! ## Why a directory listing is not enough
9//!
10//! The rules that get a diff rejected are almost never expressible as an
11//! outcome contract. They are review-time rules: "no cargo feature flags,
12//! ever"; "any change crossing the FFI boundary updates five surfaces in the
13//! same change, two of which are hand-maintained files". A model that has not
14//! read them does the *normal* thing — adds a `[features]` entry to make
15//! something optional — and produces a diff that compiles, passes its contract,
16//! and is rejected on sight. Contract-green and mergeable are different
17//! properties, and only one of them was reaching the model.
18//!
19//! ## What this loads
20//!
21//! - **Agent instructions** — `CLAUDE.md` or `AGENTS.md` at the worktree root,
22//!   plus any git-tracked nested ones, each labelled with the subtree it
23//!   governs ([`nested_instructions`]).
24//! - **`.car/` project knowledge** — identity and knowledge entries, via
25//!   [`car_memgine::project`], whose discovery already walks up from a starting
26//!   directory the way `.gitignore` resolution does.
27//!
28//! ## Two limits, stated because they are easy to mistake for coverage
29//!
30//! **Instructions that delegate are followed only one level.** This repo's own
31//! `CLAUDE.md` says the full binding surface "lives in the `car-bindings-api`
32//! skill — invoke it whenever work touches the FFI boundary". Loading the root
33//! file gets the model the *pointer*, not the rule, and closing that needs
34//! skill selection — a larger change than reading a file. [`available_skills`]
35//! narrows the gap by indexing the skills so the pointer at least resolves to
36//! a path the model can `read_file`.
37//!
38//! Nested `CLAUDE.md` files are no longer part of this limit:
39//! [`nested_instructions`] inlines them, scoped to the directory each governs.
40//!
41//! **This is untrusted-adjacent input.** The text lands in a system prompt, and
42//! it comes from the repository under work. That is the same trust level as the
43//! code the session is already reading and editing, so for a repo an operator
44//! pointed the coder at, it is fair. It would NOT be fair for content that
45//! arrived from a public tracker — see car#1081 — and nothing here should ever
46//! be extended to load one.
47
48use std::path::Path;
49
50/// Root-level files that carry agent instructions, in precedence order. Only
51/// the first one found is read: a repo with both is not asking for them to be
52/// concatenated.
53const INSTRUCTION_FILES: [&str; 2] = ["CLAUDE.md", "AGENTS.md"];
54
55/// Byte ceiling for the root instruction file.
56///
57/// Generous, because these are the rules that decide whether a diff is
58/// acceptable and truncating them mid-rule is worse than not loading them.
59///
60/// **This constant went stale once and silently ate the payload.** It was
61/// 24_000, documented as "this repo's own is ~19KB, which fits". CAR's
62/// `CLAUDE.md` then grew to 38.7KB, and the cut landed at byte 24_000 — which
63/// is 800 bytes *before* the section headed "Project conventions (hard
64/// rules)". So the coder working on CAR's own repository received every
65/// architecture note and **not one of the rules**: no cargo feature flags, no
66/// runtime toggle that picks between implementations, keep all FFI bindings in
67/// sync, documentation parity. Those are the two examples this module's own
68/// header uses to explain why it exists, and both were in the discarded 38%.
69///
70/// A file whose whole job is carrying rules that no contract can check must
71/// not lose those rules to a number nobody revisited, so
72/// `the_repos_own_instructions_fit_the_cap` now fails the build when CAR's
73/// `CLAUDE.md` outgrows this. Raise the constant when that test goes red;
74/// do not raise it silently, and do not delete the test.
75pub const MAX_INSTRUCTIONS_BYTES: usize = 64_000;
76
77/// Combined ceiling for **nested** instruction files, and the per-file cap
78/// inside it. Separate from the root budget so a deep tree cannot crowd out
79/// the root rules, which apply everywhere.
80const MAX_NESTED_INSTRUCTIONS_BYTES: usize = 12_000;
81const MAX_NESTED_FILE_BYTES: usize = 6_000;
82
83/// How many nested instruction files to inline before the rest become
84/// pointers.
85const MAX_NESTED_FILES: usize = 8;
86
87/// Below this much remaining combined budget, defer a file rather than inline
88/// a sliver of it. Truncating mid-rule is the failure this module exists to
89/// avoid; a path the model can read is strictly better than half a sentence.
90const MIN_USEFUL_NESTED_BYTES: usize = 400;
91
92/// Byte ceiling for rendered `.car/` knowledge.
93pub const MAX_KNOWLEDGE_BYTES: usize = 8_000;
94
95/// How many knowledge entries to render before stopping.
96const MAX_KNOWLEDGE_ENTRIES: usize = 40;
97
98/// Read the repository's agent-instruction file, if it has one.
99///
100/// Truncation is announced in the text rather than silent — a model that has
101/// been handed half a rule set should know that is what happened.
102pub fn agent_instructions(worktree: &Path) -> Option<(String, String)> {
103    for name in INSTRUCTION_FILES {
104        let path = worktree.join(name);
105        let Ok(raw) = std::fs::read_to_string(&path) else {
106            continue;
107        };
108        if raw.trim().is_empty() {
109            continue;
110        }
111        return Some((
112            name.to_string(),
113            truncate_note(&raw, MAX_INSTRUCTIONS_BYTES),
114        ));
115    }
116    None
117}
118
119/// Nested `CLAUDE.md` / `AGENTS.md` files — scoped rules that apply to one
120/// subtree (car#1071).
121///
122/// ## Why `git ls-files` rather than a directory walk
123///
124/// Tracked-ness is doing two jobs at once here, and both matter.
125///
126/// It is the **relevance** filter. A plain recursive walk of CAR's own
127/// checkout finds 16 instruction files; `git ls-files` finds 2. The other 14
128/// are five nested `.claude/worktrees/` checkouts of this same repository and
129/// four extracted `bench/coder-ab` fixtures — copies and test data, none of
130/// them guidance about the code under work. A skip-list would have to grow a
131/// new entry every time someone adds a build or scratch directory, and would
132/// be wrong until they did.
133///
134/// It is also the **trust** filter, which is the more important half. This
135/// text lands in a system prompt. A tracked file is one a maintainer committed
136/// and review saw; an untracked one is anything that happens to be sitting in
137/// the worktree — including a file the model itself just wrote. Reading
138/// untracked instruction files would let a session author its own rules
139/// mid-run and have them injected as maintainer intent on the next iteration.
140///
141/// ## Inlined, not merely indexed
142///
143/// [`available_skills`] indexes rather than inlines, because five skill bodies
144/// would swamp the prompt and a skill is opt-in by nature. Nested instruction
145/// files are the opposite case: they are small (CAR's is 1.2KB), they are not
146/// optional, and the failure they prevent is silent. `car-ffi-napi/CLAUDE.md`
147/// opens with *"These bugs cost many hours. Do not reintroduce them."* — a
148/// pointer to that is a rule the model has to choose to follow, and this
149/// module's header already names "loading the pointer, not the rule" as a
150/// known way this goes wrong.
151///
152/// Budgets keep that honest for a repo unlike this one: at most
153/// [`MAX_NESTED_FILES`] files, [`MAX_NESTED_FILE_BYTES`] each and
154/// [`MAX_NESTED_INSTRUCTIONS_BYTES`] combined. Anything past a budget is
155/// listed as a path the model can `read_file`, so a monorepo degrades to
156/// pointers instead of blowing the context — and is *told* that is what
157/// happened.
158///
159/// Each block is labelled with the directory it governs, because a scoped rule
160/// presented without its scope reads as a global one.
161pub fn nested_instructions(worktree: &Path) -> Option<String> {
162    let paths = tracked_instruction_files(worktree)?;
163    if paths.is_empty() {
164        return None;
165    }
166
167    let mut out = String::new();
168    let mut used = 0usize;
169    let mut deferred: Vec<String> = Vec::new();
170
171    for (i, rel) in paths.iter().enumerate() {
172        let dir = rel.rsplit_once('/').map(|(d, _)| d).unwrap_or(".");
173        let over_budget = i >= MAX_NESTED_FILES || used >= MAX_NESTED_INSTRUCTIONS_BYTES;
174        let raw = if over_budget {
175            String::new()
176        } else {
177            std::fs::read_to_string(worktree.join(rel)).unwrap_or_default()
178        };
179        if over_budget || raw.trim().is_empty() {
180            if over_budget {
181                deferred.push(rel.clone());
182            }
183            continue;
184        }
185        let remaining = MAX_NESTED_INSTRUCTIONS_BYTES.saturating_sub(used);
186        // A fragment of a rule is worse than a pointer to the whole one — the
187        // same reason the root budget is generous. If what is left of the
188        // combined budget could only show a sliver, defer the file instead of
189        // inlining a sentence and a half of it.
190        if remaining < MIN_USEFUL_NESTED_BYTES && raw.trim().len() > remaining {
191            deferred.push(rel.clone());
192            continue;
193        }
194        let body = truncate_note(raw.trim(), MAX_NESTED_FILE_BYTES.min(remaining));
195        used += body.len();
196        out.push_str(&format!(
197            "--- {rel} — applies to everything under `{dir}/` ---\n{body}\n\n"
198        ));
199    }
200
201    if !deferred.is_empty() {
202        out.push_str(
203            "Not shown, over budget. Read the file before editing anything under its \
204             directory:\n",
205        );
206        for rel in &deferred {
207            out.push_str(&format!("- {rel}\n"));
208        }
209    }
210
211    let trimmed = out.trim();
212    (!trimmed.is_empty()).then(|| trimmed.to_string())
213}
214
215/// Instruction files that git is tracking, excluding the root one
216/// [`agent_instructions`] already loaded. `None` when the worktree is not a
217/// git checkout or git is unavailable — a coder worktree always is, but tests
218/// and embedders need not be.
219fn tracked_instruction_files(worktree: &Path) -> Option<Vec<String>> {
220    let out = std::process::Command::new("git")
221        .arg("-C")
222        .arg(worktree)
223        .args(["ls-files", "-z", "--", "*CLAUDE.md", "*AGENTS.md"])
224        .output()
225        .ok()?;
226    if !out.status.success() {
227        return None;
228    }
229    let mut paths: Vec<String> = String::from_utf8_lossy(&out.stdout)
230        .split('\0')
231        .filter(|p| !p.is_empty())
232        // A root-level path has no separator; that file is already loaded in
233        // full by `agent_instructions`, and repeating it would spend the
234        // nested budget on text the model already has.
235        .filter(|p| p.contains('/'))
236        .filter(|p| {
237            let name = p.rsplit('/').next().unwrap_or(p);
238            INSTRUCTION_FILES.contains(&name)
239        })
240        .map(str::to_string)
241        .collect();
242    // Shallowest first: a rule nearer the root governs more of the tree, so it
243    // is the one most likely to matter if a budget cuts the list short.
244    paths.sort_by_key(|p| (p.matches('/').count(), p.clone()));
245    Some(paths)
246}
247
248/// Render `.car/` identity and knowledge entries, if a project is discoverable.
249pub fn dot_car_knowledge(worktree: &Path) -> Option<String> {
250    let car_dir = car_memgine::project::discover_project(worktree)?;
251    let project = car_memgine::project::load_project(&car_dir).ok()?;
252
253    let mut out = String::new();
254    if let Some(identity) = project.identity.as_deref().map(str::trim) {
255        if !identity.is_empty() {
256            out.push_str(identity);
257            out.push_str("\n\n");
258        }
259    }
260    if !project.knowledge.is_empty() {
261        out.push_str("Recorded project knowledge:\n");
262        for entry in project.knowledge.iter().take(MAX_KNOWLEDGE_ENTRIES) {
263            let kind = if entry.entry_type.is_empty() {
264                "note"
265            } else {
266                &entry.entry_type
267            };
268            out.push_str(&format!("- [{kind}] {}", entry.fact.trim()));
269            let recommendation = entry.recommendation.trim();
270            if !recommendation.is_empty() {
271                out.push_str(&format!(" — {recommendation}"));
272            }
273            out.push('\n');
274        }
275    }
276    let trimmed = out.trim();
277    (!trimmed.is_empty()).then(|| truncate_note(trimmed, MAX_KNOWLEDGE_BYTES))
278}
279
280/// The full block for the system prompt, or `None` when the repo carries
281/// neither instructions nor a `.car/` project.
282///
283/// The framing around the instruction text is deliberate. It tells the model
284/// these rules are **not** verified by the outcome contract, because the
285/// failure this fixes is a model treating a green contract as sufficient. It
286/// also pins precedence: the contract still decides done, and these decide
287/// acceptable. A model that reads "follow the repo's conventions" and then
288/// weakens a check to satisfy them has made things worse.
289pub fn project_context(worktree: &Path) -> Option<String> {
290    let instructions = agent_instructions(worktree);
291    let nested = nested_instructions(worktree);
292    let knowledge = dot_car_knowledge(worktree);
293    // Only meaningful alongside instructions that delegate to them; a skills
294    // directory with no instruction file is not something to volunteer.
295    let skills = instructions
296        .is_some()
297        .then(|| available_skills(worktree))
298        .flatten();
299    if instructions.is_none() && nested.is_none() && knowledge.is_none() {
300        return None;
301    }
302
303    let mut out = String::new();
304    if let Some((name, body)) = instructions {
305        out.push_str(&format!(
306            "PROJECT INSTRUCTIONS (from {name}, written by this repository's maintainers).\n\
307             These are review-time rules. Your outcome contract does NOT check them, so a \
308             diff can pass every check and still be rejected for breaking one. Follow them \
309             as constraints on HOW you implement, and never weaken or edit a contract check \
310             to satisfy one — the contract decides whether the work is done, these decide \
311             whether it is acceptable. Where a rule points at another document you have not \
312             been given, say so in your summary rather than guessing at its contents.\n\n\
313             {body}\n"
314        ));
315    }
316    if let Some(nested) = nested {
317        out.push_str(&format!(
318            "\nDIRECTORY-SCOPED INSTRUCTIONS.\n\
319             Each block below governs one subtree and carries the same weight as the \
320             instructions above while you are working inside it. Where a scoped rule is \
321             stricter than a root one, the scoped rule wins for that subtree.\n\n{nested}\n"
322        ));
323    }
324    if let Some(skills) = skills {
325        out.push_str(&format!("\nPROJECT SKILLS.\n{skills}\n"));
326    }
327    if let Some(knowledge) = knowledge {
328        if !out.is_empty() {
329            out.push('\n');
330        }
331        out.push_str(&format!(
332            "PROJECT KNOWLEDGE (from .car/, recorded by the team).\n\n{knowledge}\n"
333        ));
334    }
335    Some(out)
336}
337
338/// Index the repository's agent skills by name and description.
339///
340/// Instruction files delegate — this repo's `CLAUDE.md` says the full binding
341/// surface "lives in the `car-bindings-api` skill … invoke it whenever work
342/// touches the FFI boundary". Loading the root file alone hands the model a
343/// pointer it cannot follow, which is worse than useless: it knows a rule
344/// exists and not what it says.
345///
346/// Only the frontmatter is loaded, never the bodies — five skill files would
347/// swamp the prompt, and the `description` field is written precisely so a
348/// reader can decide whether it is relevant. The bodies are ordinary files in
349/// the worktree, so the model can `read_file` the one it needs. That turns a
350/// dangling reference into an instruction it can actually act on with the tools
351/// it already has.
352pub fn available_skills(worktree: &Path) -> Option<String> {
353    let skills_dir = worktree.join(".claude").join("skills");
354    let mut entries: Vec<(String, String)> = std::fs::read_dir(&skills_dir)
355        .ok()?
356        .flatten()
357        .filter_map(|entry| {
358            let manifest = entry.path().join("SKILL.md");
359            let raw = std::fs::read_to_string(&manifest).ok()?;
360            let (name, description) = parse_frontmatter(&raw)?;
361            let rel = format!(
362                ".claude/skills/{}/SKILL.md",
363                entry.file_name().to_string_lossy()
364            );
365            Some((rel, format!("**{name}** — {description}")))
366        })
367        .collect();
368    if entries.is_empty() {
369        return None;
370    }
371    entries.sort();
372
373    let mut out = String::from(
374        "The instructions above delegate to these skill documents. They are files in this \
375         worktree: when your work touches an area one of them covers, read it with \
376         `read_file` BEFORE editing, rather than guessing at what it says.\n\n",
377    );
378    for (path, summary) in entries {
379        out.push_str(&format!("- `{path}` — {summary}\n"));
380    }
381    Some(truncate_note(out.trim(), MAX_KNOWLEDGE_BYTES))
382}
383
384/// Pull `name` and `description` out of a SKILL.md YAML frontmatter block.
385///
386/// Deliberately not a YAML parser. Only two scalar fields are needed, and
387/// `description` is commonly a folded (`>-`) block whose continuation lines are
388/// indented — so a continuation is any following line that is indented and does
389/// not itself open a new key. Anything it cannot read is skipped rather than
390/// guessed at.
391fn parse_frontmatter(raw: &str) -> Option<(String, String)> {
392    let body = raw.strip_prefix("---")?;
393    let end = body.find("\n---")?;
394    let block = &body[..end];
395
396    let mut name = None;
397    let mut description: Option<String> = None;
398    let mut in_description = false;
399    for line in block.lines() {
400        if let Some(rest) = line.strip_prefix("name:") {
401            name = Some(rest.trim().to_string());
402            in_description = false;
403        } else if let Some(rest) = line.strip_prefix("description:") {
404            let first = rest.trim().trim_start_matches(['>', '|', '-']).trim();
405            description = Some(first.to_string());
406            in_description = true;
407        } else if in_description {
408            let indented = line.starts_with(' ') || line.starts_with('\t');
409            if indented && !line.trim().is_empty() {
410                let existing = description.get_or_insert_with(String::new);
411                if !existing.is_empty() {
412                    existing.push(' ');
413                }
414                existing.push_str(line.trim());
415            } else if !line.trim().is_empty() {
416                in_description = false;
417            }
418        }
419    }
420    let name = name.filter(|n| !n.is_empty())?;
421    let description = description
422        .map(|d| first_sentence(&d))
423        .filter(|d| !d.is_empty())?;
424    Some((name, description))
425}
426
427/// Skill descriptions are written as long selection blurbs. One sentence is
428/// enough for the model to decide whether to open the file.
429fn first_sentence(text: &str) -> String {
430    match text.find(". ") {
431        Some(i) => text[..=i].trim().to_string(),
432        None => text.trim().to_string(),
433    }
434}
435
436/// Cut to a byte ceiling on a UTF-8 boundary, and say so when cut.
437fn truncate_note(text: &str, max: usize) -> String {
438    if text.len() <= max {
439        return text.to_string();
440    }
441    let mut end = max;
442    while end > 0 && !text.is_char_boundary(end) {
443        end -= 1;
444    }
445    format!(
446        "{}\n\n[truncated: {} of {} bytes shown]",
447        &text[..end],
448        end,
449        text.len()
450    )
451}
452
453#[cfg(test)]
454mod tests {
455    use super::*;
456
457    #[test]
458    fn reads_claude_md_and_prefers_it_over_agents_md() {
459        let dir = tempfile::tempdir().unwrap();
460        std::fs::write(dir.path().join("CLAUDE.md"), "no feature flags, ever").unwrap();
461        std::fs::write(dir.path().join("AGENTS.md"), "something else").unwrap();
462        let (name, body) = agent_instructions(dir.path()).expect("instructions found");
463        assert_eq!(name, "CLAUDE.md");
464        assert!(body.contains("no feature flags"));
465    }
466
467    #[test]
468    fn falls_back_to_agents_md() {
469        let dir = tempfile::tempdir().unwrap();
470        std::fs::write(dir.path().join("AGENTS.md"), "house rules").unwrap();
471        let (name, _) = agent_instructions(dir.path()).expect("instructions found");
472        assert_eq!(name, "AGENTS.md");
473    }
474
475    #[test]
476    fn an_empty_instruction_file_is_not_instructions() {
477        let dir = tempfile::tempdir().unwrap();
478        std::fs::write(dir.path().join("CLAUDE.md"), "   \n\n").unwrap();
479        assert!(agent_instructions(dir.path()).is_none());
480    }
481
482    #[test]
483    fn a_repo_with_nothing_yields_no_block() {
484        let dir = tempfile::tempdir().unwrap();
485        assert!(project_context(dir.path()).is_none());
486    }
487
488    #[test]
489    fn the_block_says_the_contract_does_not_check_these_rules() {
490        // The whole failure this fixes is a model treating a green contract as
491        // sufficient, so the framing is load-bearing, not decoration.
492        let dir = tempfile::tempdir().unwrap();
493        std::fs::write(dir.path().join("CLAUDE.md"), "rule one").unwrap();
494        let block = project_context(dir.path()).expect("block");
495        assert!(block.contains("does NOT check them"));
496        assert!(block.contains("never weaken or edit a contract check"));
497        assert!(block.contains("rule one"));
498    }
499
500    #[test]
501    fn truncation_is_announced_not_silent() {
502        let dir = tempfile::tempdir().unwrap();
503        let huge = "x".repeat(MAX_INSTRUCTIONS_BYTES + 500);
504        std::fs::write(dir.path().join("CLAUDE.md"), &huge).unwrap();
505        let (_, body) = agent_instructions(dir.path()).expect("instructions");
506        assert!(
507            body.contains("[truncated:"),
508            "a silent cut hides missing rules"
509        );
510        assert!(body.len() < huge.len() + 200);
511    }
512
513    #[test]
514    fn truncation_lands_on_a_char_boundary() {
515        // A multi-byte char straddling the ceiling must not panic or corrupt.
516        let text = "é".repeat(100);
517        let cut = truncate_note(&text, 51);
518        assert!(cut.contains("[truncated:"));
519    }
520
521    fn write_skill(root: &std::path::Path, dir: &str, frontmatter: &str) {
522        let d = root.join(".claude").join("skills").join(dir);
523        std::fs::create_dir_all(&d).unwrap();
524        std::fs::write(d.join("SKILL.md"), frontmatter).unwrap();
525    }
526
527    #[test]
528    fn skills_are_indexed_by_name_and_first_sentence() {
529        let dir = tempfile::tempdir().unwrap();
530        std::fs::write(dir.path().join("CLAUDE.md"), "see the skills").unwrap();
531        write_skill(
532            dir.path(),
533            "car-bindings-api",
534            "---\nname: car-bindings-api\ndescription: >-\n  The complete CAR bindings API\n  surface. Use it whenever work touches the FFI boundary.\n---\nbody text here\n",
535        );
536        let block = project_context(dir.path()).expect("block");
537        assert!(block.contains("car-bindings-api"));
538        assert!(block.contains("The complete CAR bindings API surface."));
539        // The path must be there, because the point is that the model can open it.
540        assert!(block.contains(".claude/skills/car-bindings-api/SKILL.md"));
541        assert!(block.contains("read_file"));
542        // The body is NOT inlined — five of these would swamp the prompt.
543        assert!(!block.contains("body text here"));
544    }
545
546    #[test]
547    fn a_folded_description_is_joined_not_truncated_at_the_newline() {
548        let (name, desc) = parse_frontmatter(
549            "---\nname: thing\ndescription: >-\n  first part\n  second part. Rest.\n---\n",
550        )
551        .expect("parsed");
552        assert_eq!(name, "thing");
553        assert_eq!(desc, "first part second part.");
554    }
555
556    #[test]
557    fn a_skill_without_usable_frontmatter_is_skipped_not_guessed() {
558        let dir = tempfile::tempdir().unwrap();
559        std::fs::write(dir.path().join("CLAUDE.md"), "rules").unwrap();
560        write_skill(dir.path(), "broken", "no frontmatter at all\n");
561        write_skill(
562            dir.path(),
563            "good",
564            "---\nname: good\ndescription: Does a thing.\n---\n",
565        );
566        let block = project_context(dir.path()).expect("block");
567        assert!(block.contains("good"));
568        assert!(!block.contains("broken"));
569    }
570
571    #[test]
572    fn skills_are_not_volunteered_without_instructions_that_delegate() {
573        let dir = tempfile::tempdir().unwrap();
574        write_skill(
575            dir.path(),
576            "lonely",
577            "---\nname: lonely\ndescription: Nobody points here.\n---\n",
578        );
579        // No CLAUDE.md and no .car/ — nothing to attach the index to.
580        assert!(project_context(dir.path()).is_none());
581    }
582
583    #[test]
584    fn dot_car_knowledge_is_rendered_with_recommendations() {
585        let dir = tempfile::tempdir().unwrap();
586        let car = dir.path().join(".car");
587        std::fs::create_dir_all(car.join("knowledge")).unwrap();
588        std::fs::write(car.join("identity.md"), "The CAR runtime.").unwrap();
589        std::fs::write(
590            car.join("knowledge").join("gotchas.jsonl"),
591            r#"{"id":"g1","type":"gotcha","fact":"cargo config follows cwd","recommendation":"run from car-rs"}"#,
592        )
593        .unwrap();
594        let rendered = dot_car_knowledge(dir.path()).expect("knowledge loaded");
595        assert!(rendered.contains("The CAR runtime."));
596        assert!(rendered.contains("cargo config follows cwd"));
597        assert!(rendered.contains("run from car-rs"));
598        assert!(rendered.contains("[gotcha]"));
599    }
600
601    #[test]
602    fn discovery_walks_up_from_a_nested_worktree() {
603        // Same rule as .gitignore resolution: a coder session cut at a
604        // subdirectory still finds the project.
605        let dir = tempfile::tempdir().unwrap();
606        std::fs::create_dir_all(dir.path().join(".car")).unwrap();
607        std::fs::write(dir.path().join(".car").join("identity.md"), "root project").unwrap();
608        let nested = dir.path().join("crates").join("thing");
609        std::fs::create_dir_all(&nested).unwrap();
610        let rendered = dot_car_knowledge(&nested).expect("found by walking up");
611        assert!(rendered.contains("root project"));
612    }
613
614    // ---- car#1071: nested instruction files, and the cap that ate the rules --
615
616    /// Make `dir` a git repo with `files` committed. Nested instruction files
617    /// are found through `git ls-files`, so a plain tempdir will not do.
618    fn repo_with(files: &[(&str, &str)]) -> tempfile::TempDir {
619        let dir = tempfile::tempdir().unwrap();
620        let git = |args: &[&str]| {
621            let ok = std::process::Command::new("git")
622                .arg("-C")
623                .arg(dir.path())
624                .args(args)
625                .output()
626                .unwrap()
627                .status
628                .success();
629            assert!(ok, "git {args:?} failed");
630        };
631        git(&["init", "-q"]);
632        git(&["config", "user.email", "t@example.com"]);
633        git(&["config", "user.name", "t"]);
634        for (rel, body) in files {
635            let path = dir.path().join(rel);
636            std::fs::create_dir_all(path.parent().unwrap()).unwrap();
637            std::fs::write(&path, body).unwrap();
638        }
639        git(&["add", "-A"]);
640        git(&["commit", "-qm", "seed"]);
641        dir
642    }
643
644    #[test]
645    fn a_nested_instruction_file_is_inlined_with_the_directory_it_governs() {
646        let dir = repo_with(&[
647            ("CLAUDE.md", "root rules"),
648            ("crates/napi/CLAUDE.md", "do not reintroduce the five bugs"),
649        ]);
650
651        let nested = nested_instructions(dir.path()).expect("a nested file is found");
652        assert!(
653            nested.contains("do not reintroduce the five bugs"),
654            "the nested rule must be inlined, not merely pointed at: {nested}"
655        );
656        assert!(
657            nested.contains("crates/napi/CLAUDE.md") && nested.contains("`crates/napi/`"),
658            "a scoped rule shown without its scope reads as a global one: {nested}"
659        );
660    }
661
662    #[test]
663    fn the_root_file_is_not_repeated_in_the_nested_block() {
664        let dir = repo_with(&[("CLAUDE.md", "root rules"), ("sub/CLAUDE.md", "sub rules")]);
665        let nested = nested_instructions(dir.path()).unwrap();
666        assert!(
667            !nested.contains("root rules"),
668            "agent_instructions already loads the root file in full: {nested}"
669        );
670    }
671
672    /// The relevance half of using `git ls-files`. A recursive walk of CAR's
673    /// own checkout finds 16 instruction files, 14 of them inside nested
674    /// worktrees and extracted bench fixtures.
675    #[test]
676    fn an_untracked_instruction_file_is_ignored() {
677        let dir = repo_with(&[("CLAUDE.md", "root rules"), ("sub/CLAUDE.md", "tracked")]);
678        // Written after the commit, exactly like a build artifact or a nested
679        // scratch checkout.
680        std::fs::create_dir_all(dir.path().join("target/scratch")).unwrap();
681        std::fs::write(
682            dir.path().join("target/scratch/CLAUDE.md"),
683            "not maintainer intent",
684        )
685        .unwrap();
686
687        let nested = nested_instructions(dir.path()).unwrap();
688        assert!(nested.contains("tracked"));
689        assert!(
690            !nested.contains("not maintainer intent"),
691            "untracked text must not reach the system prompt — a session could \
692             otherwise write its own rules mid-run: {nested}"
693        );
694    }
695
696    #[test]
697    fn a_worktree_that_is_not_a_git_checkout_yields_nothing_rather_than_failing() {
698        let dir = tempfile::tempdir().unwrap();
699        std::fs::write(dir.path().join("CLAUDE.md"), "rules").unwrap();
700        assert!(nested_instructions(dir.path()).is_none());
701        // …and the surrounding block still works without it.
702        assert!(project_context(dir.path()).unwrap().contains("rules"));
703    }
704
705    #[test]
706    fn past_the_file_budget_the_rest_become_readable_pointers() {
707        let mut files: Vec<(String, String)> = vec![("CLAUDE.md".into(), "root".into())];
708        for i in 0..(MAX_NESTED_FILES + 3) {
709            files.push((format!("d{i:02}/CLAUDE.md"), format!("rule {i}")));
710        }
711        let refs: Vec<(&str, &str)> = files
712            .iter()
713            .map(|(a, b)| (a.as_str(), b.as_str()))
714            .collect();
715        let dir = repo_with(&refs);
716
717        let nested = nested_instructions(dir.path()).unwrap();
718        assert!(
719            nested.contains("Not shown, over budget"),
720            "a repo past the budget must be TOLD it is seeing pointers: {nested}"
721        );
722        assert!(
723            nested.contains("rule 0"),
724            "the first files are still inlined"
725        );
726        assert!(
727            nested.contains(&format!("d{:02}/CLAUDE.md", MAX_NESTED_FILES + 2)),
728            "an over-budget file is still named so the model can read it"
729        );
730    }
731
732    #[test]
733    fn nested_instructions_reach_the_prompt_block_with_their_precedence_stated() {
734        let dir = repo_with(&[
735            ("CLAUDE.md", "root rules"),
736            ("crates/napi/CLAUDE.md", "napi gotchas"),
737        ]);
738        let block = project_context(dir.path()).expect("a block");
739        assert!(block.contains("DIRECTORY-SCOPED INSTRUCTIONS"));
740        assert!(block.contains("napi gotchas"));
741        assert!(
742            block.contains("the scoped rule wins for that subtree"),
743            "precedence between root and scoped rules must be stated, not guessed: {block}"
744        );
745    }
746
747    /// End-to-end against THIS repository, which is the only place the whole
748    /// chain can be checked: git tracking, the root budget, the nested budget,
749    /// and the prompt framing all at once. Skipped in a checkout that does not
750    /// look like CAR, so a vendored copy does not fail someone else's build.
751    #[test]
752    fn car_s_own_repo_yields_both_its_hard_rules_and_its_napi_gotchas() {
753        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
754            .ancestors()
755            .nth(3)
756            .unwrap();
757        if !root.join("car-rs/crates/car-ffi-napi/CLAUDE.md").exists() {
758            return;
759        }
760        let block = project_context(root).expect("CAR has instructions");
761
762        // The regression that motivated raising the cap: these headings live
763        // past byte 24_000 of CLAUDE.md and were being discarded entirely.
764        assert!(
765            block.contains("No cargo feature flags"),
766            "the hard rules must survive the root budget"
767        );
768        assert!(block.contains("Keep all FFI bindings in sync"));
769        assert!(
770            !block.contains("[truncated:"),
771            "CAR's own instructions must not be truncated at all"
772        );
773
774        // And the nested half of car#1071.
775        assert!(
776            block.contains("Do not reintroduce them"),
777            "car-ffi-napi/CLAUDE.md must reach the prompt"
778        );
779        assert!(block.contains("car-rs/crates/car-ffi-napi/CLAUDE.md"));
780    }
781
782    /// A file that would only fit as a sliver is deferred whole, not inlined
783    /// as half a sentence — the same principle as the generous root budget.
784    #[test]
785    fn a_file_that_would_only_fit_as_a_fragment_is_deferred_instead() {
786        let big = "r".repeat(MAX_NESTED_FILE_BYTES);
787        let mut files: Vec<(String, String)> = vec![("CLAUDE.md".into(), "root".into())];
788        // Two full-size files exhaust the combined budget to within a sliver.
789        for i in 0..2 {
790            files.push((format!("d{i}/CLAUDE.md"), big.clone()));
791        }
792        files.push(("zz/CLAUDE.md".into(), "z".repeat(5_000)));
793        let refs: Vec<(&str, &str)> = files
794            .iter()
795            .map(|(a, b)| (a.as_str(), b.as_str()))
796            .collect();
797        let dir = repo_with(&refs);
798
799        let nested = nested_instructions(dir.path()).unwrap();
800        assert!(
801            nested.contains("zz/CLAUDE.md"),
802            "the deferred file must still be named: {}",
803            &nested[nested.len().saturating_sub(400)..]
804        );
805        assert!(
806            !nested.contains(&"z".repeat(200)),
807            "a sliver of the deferred file must not be inlined"
808        );
809    }
810
811    /// **The anti-staleness guard. Do not delete this test.**
812    ///
813    /// `MAX_INSTRUCTIONS_BYTES` was 24_000, with a comment reading "this repo's
814    /// own is ~19KB, which fits". CAR's `CLAUDE.md` grew to 38.7KB and the cut
815    /// landed 800 bytes before the heading "Project conventions (hard rules)",
816    /// so the coder working on this repository got every architecture note and
817    /// none of the rules — including the two this module's header cites as its
818    /// reason for existing.
819    ///
820    /// Truncation was announced in the text, which is why nothing caught it:
821    /// the model was told it had 62% of a document, not that the missing 38%
822    /// was the entire rules section. A budget for rules has to be checked
823    /// against the rules, so this asserts on the real file.
824    #[test]
825    fn the_repos_own_instructions_fit_the_cap() {
826        let root = Path::new(env!("CARGO_MANIFEST_DIR"))
827            .ancestors()
828            .nth(3)
829            .expect("crates/car-server-core is three levels below the repo root");
830        let claude_md = root.join("CLAUDE.md");
831        let Ok(raw) = std::fs::read_to_string(&claude_md) else {
832            // Vendored or partial checkout — nothing to assert against.
833            return;
834        };
835        assert!(
836            raw.len() <= MAX_INSTRUCTIONS_BYTES,
837            "CAR's own CLAUDE.md is {} bytes and MAX_INSTRUCTIONS_BYTES is {}, so the \
838             coder working on this repo is silently losing the tail of its own rules. \
839             Raise the constant (and read its doc comment first) — do not delete this test.",
840            raw.len(),
841            MAX_INSTRUCTIONS_BYTES
842        );
843    }
844
845    /// The guard above is only worth having if it can fail, and the shape it
846    /// guards against is "the cut lands before the rules". This proves the
847    /// truncation it describes is real rather than hypothetical.
848    #[test]
849    fn truncation_drops_the_tail_it_claims_to() {
850        let text = format!(
851            "{}\n## Project conventions (hard rules)\nno feature flags",
852            "x".repeat(100)
853        );
854        let cut = truncate_note(&text, 50);
855        assert!(!cut.contains("hard rules"), "the tail really is discarded");
856        assert!(
857            cut.contains("[truncated: 50 of"),
858            "and the loss is announced"
859        );
860    }
861}