~/.claude/CLAUDE.md, then CLAUDE.md, .claude/CLAUDE.md and CLAUDE.local.md from the top of the project down to the root, with @imports up to five deep that may not leave the tree their file belongs to. Read once and appended to the system prompt, so the cached prefix stays constant. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Showing 4 changed files with 442 additions and 0 deletions.
| @@ -321,6 +321,13 @@ type Options struct { | ||
| 321 | 321 | NoCache bool |
| 322 | 322 | // Stream uses the SSE endpoint so output appears as it is produced. |
| 323 | 323 | Stream bool |
| 324 | + // Instructions is the project's standing instructions — the CLAUDE.md | |
| 325 | + // files, already formatted as a section of the system prompt — appended | |
| 326 | + // after Codey's own. Read once at startup by whoever builds the agent, so | |
| 327 | + // it is constant for the session like everything else in the prefix. | |
| 328 | + // Empty leaves the prompt as it was: the model is still told to read a | |
| 329 | + // CLAUDE.md or AGENTS.md for itself. | |
| 330 | + Instructions string | |
| 324 | 331 | // Skills are the instruction sets discovered at startup. Their names and |
| 325 | 332 | // descriptions are appended to the system prompt; the bodies are loaded on |
| 326 | 333 | // demand by the skill tool. |
| @@ -553,6 +560,7 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log | ||
| 553 | 560 | if opts.System == "" { |
| 554 | 561 | opts.System = defaultSystem |
| 555 | 562 | } |
| 563 | + opts.System += opts.Instructions | |
| 556 | 564 | opts.System += skillListing(opts.Skills) |
| 557 | 565 | opts.System += modelListing(opts.Models) |
| 558 | 566 | return &Agent{client: client, tools: reg, state: state, log: log, opts: opts} |
| @@ -815,6 +815,23 @@ func TestSystemPromptListsSkills(t *testing.T) { | ||
| 815 | 815 | } |
| 816 | 816 | } |
| 817 | 817 | |
| 818 | + // The project's instructions follow Codey's own and come before the skill | |
| 819 | + // listing, so the defaults they outrank are the ones already read. | |
| 820 | + func TestSystemPromptCarriesInstructions(t *testing.T) { | |
| 821 | + const section = "\n\n# Instructions from CLAUDE.md\n\nRun make test." | |
| 822 | + a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{ | |
| 823 | + Instructions: section, | |
| 824 | + Skills: []skills.Skill{{Name: "s", Description: "d"}}, | |
| 825 | + }) | |
| 826 | + sys := a.opts.System | |
| 827 | + if !strings.HasPrefix(sys, defaultSystem+section) { | |
| 828 | + t.Errorf("instructions not appended straight after the default prompt:\n%s", sys) | |
| 829 | + } | |
| 830 | + if !strings.Contains(sys, "s: d") { | |
| 831 | + t.Error("the skill listing was lost") | |
| 832 | + } | |
| 833 | + } | |
| 834 | + | |
| 818 | 835 | func TestSystemPromptUnchangedWithoutSkills(t *testing.T) { |
| 819 | 836 | a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{}) |
| 820 | 837 | if a.opts.System != defaultSystem { |
| @@ -0,0 +1,247 @@ | ||
| 1 | + package ccsettings | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "fmt" | |
| 5 | + "os" | |
| 6 | + "path/filepath" | |
| 7 | + "regexp" | |
| 8 | + "strings" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + // Instruction is one CLAUDE.md-style file, read. | |
| 12 | + type Instruction struct { | |
| 13 | + Path string | |
| 14 | + // Shown names the file in the prompt, and Role says whose it is. | |
| 15 | + Shown string | |
| 16 | + Role string | |
| 17 | + Body string | |
| 18 | + } | |
| 19 | + | |
| 20 | + // Roles, as the prompt describes them. Saying whose a file is matters to the | |
| 21 | + // model as much as to a person: the user's own preferences and a repository's | |
| 22 | + // conventions are different kinds of instruction. | |
| 23 | + const ( | |
| 24 | + roleUser = "the user's own instructions, for every project" | |
| 25 | + roleProject = "this project's instructions, checked in" | |
| 26 | + roleLocal = "the user's private instructions for this project" | |
| 27 | + importMaxHop = 5 | |
| 28 | + // maxInstructionBytes bounds one file. These go into every request, and | |
| 29 | + // a generated file or a stray log someone named CLAUDE.md should not be | |
| 30 | + // able to fill the window. | |
| 31 | + maxInstructionBytes = 64 << 10 | |
| 32 | + ) | |
| 33 | + | |
| 34 | + // LoadInstructions reads the files Claude Code loads into a conversation | |
| 35 | + // started in root, in the order it loads them, least specific first: | |
| 36 | + // | |
| 37 | + // - ~/.claude/CLAUDE.md, the user's own; | |
| 38 | + // - then for each directory from the top of the project down to root — | |
| 39 | + // the top being the nearest directory with a .git in it, or the home | |
| 40 | + // directory if there is none on the way — CLAUDE.md, .claude/CLAUDE.md | |
| 41 | + // and CLAUDE.local.md. | |
| 42 | + // | |
| 43 | + // Each file's @path imports follow it, up to five hops deep. An import is | |
| 44 | + // relative to the file it appears in, and may not leave the tree its file | |
| 45 | + // belongs to: a project's files may import from the project, the user's from | |
| 46 | + // the home directory. A repository's CLAUDE.md that could import | |
| 47 | + // @~/.aws/credentials would be a repository reading your credentials into | |
| 48 | + // the conversation, which is then sent to the model. | |
| 49 | + // | |
| 50 | + // Claude Code also loads a subdirectory's CLAUDE.md when it first reads a | |
| 51 | + // file there, and .claude/rules/; neither is done here. | |
| 52 | + func LoadInstructions(home, root string) ([]Instruction, []error) { | |
| 53 | + l := &instructionLoader{home: home, root: root, seen: map[string]bool{}} | |
| 54 | + if home != "" { | |
| 55 | + l.load(filepath.Join(home, UserInstructions), roleUser, home, "", 0) | |
| 56 | + } | |
| 57 | + if root == "" { | |
| 58 | + return l.out, l.errs | |
| 59 | + } | |
| 60 | + dirs := projectDirs(home, root) | |
| 61 | + top := dirs[0] | |
| 62 | + for _, dir := range dirs { | |
| 63 | + for _, name := range []string{instructionsName, filepath.Join(".claude", instructionsName), localInstructions} { | |
| 64 | + path := filepath.Join(dir, name) | |
| 65 | + role, bound := roleProject, top | |
| 66 | + if name == localInstructions { | |
| 67 | + role = roleLocal | |
| 68 | + // A CLAUDE.local.md is the user's, and may reach into their | |
| 69 | + // home directory — Claude Code's own docs suggest importing a | |
| 70 | + // personal file from there — unless git is tracking it, which | |
| 71 | + // makes it the repository's after all. | |
| 72 | + if _, err := os.Lstat(path); err != nil { | |
| 73 | + continue | |
| 74 | + } | |
| 75 | + if home != "" && !gitTracks(dir, localInstructions) { | |
| 76 | + bound = home | |
| 77 | + } | |
| 78 | + } | |
| 79 | + l.load(path, role, bound, "", 0) | |
| 80 | + } | |
| 81 | + } | |
| 82 | + return l.out, l.errs | |
| 83 | + } | |
| 84 | + | |
| 85 | + // projectDirs is root and the directories above it that belong to the same | |
| 86 | + // project, outermost first. The walk stops at a directory holding .git, or at | |
| 87 | + // the home directory. A root that is neither in a repository nor under home | |
| 88 | + // is a project of one directory: walking up to / would read whatever CLAUDE.md | |
| 89 | + // happened to be lying in /tmp. | |
| 90 | + func projectDirs(home, root string) []string { | |
| 91 | + root = filepath.Clean(root) | |
| 92 | + var dirs []string | |
| 93 | + for d := root; ; { | |
| 94 | + dirs = append(dirs, d) | |
| 95 | + if _, err := os.Lstat(filepath.Join(d, ".git")); err == nil { | |
| 96 | + break | |
| 97 | + } | |
| 98 | + if home != "" && d == filepath.Clean(home) { | |
| 99 | + break | |
| 100 | + } | |
| 101 | + parent := filepath.Dir(d) | |
| 102 | + if parent == d { | |
| 103 | + return []string{root} | |
| 104 | + } | |
| 105 | + d = parent | |
| 106 | + } | |
| 107 | + for i, j := 0, len(dirs)-1; i < j; i, j = i+1, j-1 { | |
| 108 | + dirs[i], dirs[j] = dirs[j], dirs[i] | |
| 109 | + } | |
| 110 | + return dirs | |
| 111 | + } | |
| 112 | + | |
| 113 | + type instructionLoader struct { | |
| 114 | + home string | |
| 115 | + root string | |
| 116 | + seen map[string]bool | |
| 117 | + out []Instruction | |
| 118 | + errs []error | |
| 119 | + } | |
| 120 | + | |
| 121 | + // load reads one file and then its imports. bound is the directory the file | |
| 122 | + // and everything it imports must stay inside; importer is the file that | |
| 123 | + // imported this one, empty for a file found by name. | |
| 124 | + func (l *instructionLoader) load(path, role, bound, importer string, hop int) { | |
| 125 | + path = filepath.Clean(path) | |
| 126 | + real, err := filepath.EvalSymlinks(path) | |
| 127 | + if err != nil { | |
| 128 | + return // not there, which is the usual case | |
| 129 | + } | |
| 130 | + if l.seen[real] { | |
| 131 | + return | |
| 132 | + } | |
| 133 | + // The user's own file is wherever they keep it — often a symlink into a | |
| 134 | + // dotfiles repository. Everything else, including what it imports, stays | |
| 135 | + // inside its bound, and that is checked on the resolved path: a symlink | |
| 136 | + // committed to a repository can point anywhere. | |
| 137 | + ownFile := importer == "" && role == roleUser | |
| 138 | + if !ownFile && !within(bound, real) { | |
| 139 | + if importer != "" { | |
| 140 | + where := "the project" | |
| 141 | + if bound == l.home { | |
| 142 | + where = "the home directory" | |
| 143 | + } | |
| 144 | + l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %s imports %s, which is outside %s — skipped", l.shown(importer), l.shown(path), where)) | |
| 145 | + } | |
| 146 | + return | |
| 147 | + } | |
| 148 | + info, err := os.Stat(real) | |
| 149 | + if err != nil || !info.Mode().IsRegular() { | |
| 150 | + return | |
| 151 | + } | |
| 152 | + raw, err := os.ReadFile(real) | |
| 153 | + if err != nil { | |
| 154 | + l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %w", err)) | |
| 155 | + return | |
| 156 | + } | |
| 157 | + l.seen[real] = true | |
| 158 | + body := string(raw) | |
| 159 | + if len(body) > maxInstructionBytes { | |
| 160 | + l.errs = append(l.errs, fmt.Errorf("CLAUDE.md: %s is over %d KB — only the start of it is used", l.shown(path), maxInstructionBytes>>10)) | |
| 161 | + body = body[:maxInstructionBytes] | |
| 162 | + } | |
| 163 | + in := Instruction{Path: path, Shown: l.shown(path), Role: role, Body: strings.TrimSpace(body)} | |
| 164 | + if importer != "" { | |
| 165 | + in.Role = "imported by " + l.shown(importer) | |
| 166 | + } | |
| 167 | + if in.Body != "" { | |
| 168 | + l.out = append(l.out, in) | |
| 169 | + } | |
| 170 | + if hop >= importMaxHop { | |
| 171 | + return | |
| 172 | + } | |
| 173 | + for _, ref := range imports(body) { | |
| 174 | + target := ref | |
| 175 | + switch { | |
| 176 | + case strings.HasPrefix(ref, "~/"): | |
| 177 | + if l.home == "" { | |
| 178 | + continue | |
| 179 | + } | |
| 180 | + target = filepath.Join(l.home, ref[2:]) | |
| 181 | + case !filepath.IsAbs(ref): | |
| 182 | + target = filepath.Join(filepath.Dir(path), ref) | |
| 183 | + } | |
| 184 | + l.load(target, role, bound, path, hop+1) | |
| 185 | + } | |
| 186 | + } | |
| 187 | + | |
| 188 | + func (l *instructionLoader) shown(path string) string { | |
| 189 | + return shown(l.home, l.root, path) | |
| 190 | + } | |
| 191 | + | |
| 192 | + func within(dir, path string) bool { | |
| 193 | + if real, err := filepath.EvalSymlinks(dir); err == nil { | |
| 194 | + dir = real | |
| 195 | + } | |
| 196 | + rel, err := filepath.Rel(dir, path) | |
| 197 | + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) | |
| 198 | + } | |
| 199 | + | |
| 200 | + var ( | |
| 201 | + importRef = regexp.MustCompile("(?:^|[\\s(])@([^\\s`]+)") | |
| 202 | + inlineCode = regexp.MustCompile("`[^`]*`") | |
| 203 | + ) | |
| 204 | + | |
| 205 | + // imports finds the @path references in a file, skipping code: an @ in a | |
| 206 | + // code block or span is an example, or a decorator, not an import. | |
| 207 | + func imports(body string) []string { | |
| 208 | + var out []string | |
| 209 | + fenced := false | |
| 210 | + for _, line := range strings.Split(body, "\n") { | |
| 211 | + if strings.HasPrefix(strings.TrimSpace(line), "```") { | |
| 212 | + fenced = !fenced | |
| 213 | + continue | |
| 214 | + } | |
| 215 | + if fenced { | |
| 216 | + continue | |
| 217 | + } | |
| 218 | + line = inlineCode.ReplaceAllString(line, "") | |
| 219 | + for _, m := range importRef.FindAllStringSubmatch(line, -1) { | |
| 220 | + ref := strings.TrimRight(m[1], ".,;:!?)]'\"") | |
| 221 | + if ref != "" { | |
| 222 | + out = append(out, ref) | |
| 223 | + } | |
| 224 | + } | |
| 225 | + } | |
| 226 | + return out | |
| 227 | + } | |
| 228 | + | |
| 229 | + // FormatInstructions is the system prompt section the files make. It is built | |
| 230 | + // once, at startup, and so is constant for the session: the cached prefix | |
| 231 | + // still matches request to request. | |
| 232 | + func FormatInstructions(files []Instruction) string { | |
| 233 | + if len(files) == 0 { | |
| 234 | + return "" | |
| 235 | + } | |
| 236 | + var b strings.Builder | |
| 237 | + b.WriteString("\n\n# Instructions from CLAUDE.md\n\n" + | |
| 238 | + "These are the instruction files Claude Code would load for this repository,\n" + | |
| 239 | + "read at startup and given here in full, so there is no need to read them\n" + | |
| 240 | + "again. Follow them: on anything about this codebase they outrank the\n" + | |
| 241 | + "defaults above. Later files are more specific to where you are working, and\n" + | |
| 242 | + "win where two disagree.") | |
| 243 | + for _, f := range files { | |
| 244 | + fmt.Fprintf(&b, "\n\n## %s (%s)\n\n%s", f.Shown, f.Role, f.Body) | |
| 245 | + } | |
| 246 | + return b.String() | |
| 247 | + } | |
| @@ -0,0 +1,170 @@ | ||
| 1 | + package ccsettings | |
| 2 | + | |
| 3 | + import ( | |
| 4 | + "os" | |
| 5 | + "path/filepath" | |
| 6 | + "reflect" | |
| 7 | + "strings" | |
| 8 | + "testing" | |
| 9 | + ) | |
| 10 | + | |
| 11 | + func shownOf(files []Instruction) []string { | |
| 12 | + var out []string | |
| 13 | + for _, f := range files { | |
| 14 | + out = append(out, f.Shown) | |
| 15 | + } | |
| 16 | + return out | |
| 17 | + } | |
| 18 | + | |
| 19 | + func TestLoadInstructionsOrder(t *testing.T) { | |
| 20 | + home, _ := dirs(t) | |
| 21 | + repo := filepath.Join(home, "src", "repo") | |
| 22 | + root := filepath.Join(repo, "services", "api") | |
| 23 | + os.MkdirAll(filepath.Join(repo, ".git"), 0o755) | |
| 24 | + os.MkdirAll(root, 0o755) | |
| 25 | + | |
| 26 | + write(t, home, UserInstructions, "user rules") | |
| 27 | + // Above the git root: not part of this project. | |
| 28 | + write(t, home, "src/CLAUDE.md", "outside the repository") | |
| 29 | + write(t, repo, "CLAUDE.md", "repo rules") | |
| 30 | + write(t, repo, ".claude/CLAUDE.md", "repo dot-claude rules") | |
| 31 | + write(t, filepath.Join(repo, "services"), "CLAUDE.md", "services rules") | |
| 32 | + write(t, root, "CLAUDE.md", "api rules") | |
| 33 | + write(t, root, "CLAUDE.local.md", "my api notes") | |
| 34 | + write(t, root, "AGENTS.md", "not Claude's file") | |
| 35 | + | |
| 36 | + files, errs := LoadInstructions(home, root) | |
| 37 | + if len(errs) != 0 { | |
| 38 | + t.Fatalf("errors: %v", errs) | |
| 39 | + } | |
| 40 | + got := shownOf(files) | |
| 41 | + want := []string{ | |
| 42 | + "~/.claude/CLAUDE.md", | |
| 43 | + "~/src/repo/CLAUDE.md", | |
| 44 | + "~/src/repo/.claude/CLAUDE.md", | |
| 45 | + "~/src/repo/services/CLAUDE.md", | |
| 46 | + "CLAUDE.md", | |
| 47 | + "CLAUDE.local.md", | |
| 48 | + } | |
| 49 | + if !reflect.DeepEqual(got, want) { | |
| 50 | + t.Errorf("got %q\nwant %q", got, want) | |
| 51 | + } | |
| 52 | + if files[0].Role != roleUser || files[5].Role != roleLocal || files[4].Role != roleProject { | |
| 53 | + t.Errorf("roles: %q %q %q", files[0].Role, files[4].Role, files[5].Role) | |
| 54 | + } | |
| 55 | + } | |
| 56 | + | |
| 57 | + // With no repository on the way up, the walk stops at the home directory | |
| 58 | + // rather than reading whatever lies above it. | |
| 59 | + func TestLoadInstructionsNoRepository(t *testing.T) { | |
| 60 | + home, root := dirs(t) | |
| 61 | + write(t, home, "CLAUDE.md", "home-level") | |
| 62 | + write(t, filepath.Dir(home), "CLAUDE.md", "above home") | |
| 63 | + write(t, root, "CLAUDE.md", "here") | |
| 64 | + files, _ := LoadInstructions(home, root) | |
| 65 | + got := shownOf(files) | |
| 66 | + want := []string{"~/CLAUDE.md", "CLAUDE.md"} | |
| 67 | + if !reflect.DeepEqual(got, want) { | |
| 68 | + t.Errorf("got %q, want %q", got, want) | |
| 69 | + } | |
| 70 | + | |
| 71 | + // Outside home altogether, only the root itself. | |
| 72 | + elsewhere := filepath.Join(filepath.Dir(home), "tmp", "x") | |
| 73 | + os.MkdirAll(elsewhere, 0o755) | |
| 74 | + write(t, elsewhere, "CLAUDE.md", "x") | |
| 75 | + files, _ = LoadInstructions(home, elsewhere) | |
| 76 | + if len(files) != 1 || files[0].Body != "x" { | |
| 77 | + t.Errorf("got %+v", files) | |
| 78 | + } | |
| 79 | + } | |
| 80 | + | |
| 81 | + func TestInstructionImports(t *testing.T) { | |
| 82 | + home, root := dirs(t) | |
| 83 | + os.MkdirAll(filepath.Join(root, ".git"), 0o755) | |
| 84 | + write(t, root, "CLAUDE.md", strings.Join([]string{ | |
| 85 | + "See @AGENTS.md for the rules, and @docs/style.md.", | |
| 86 | + "Mail me at someone@example.com, which is not an import.", | |
| 87 | + "`@inline` is code, and so is this:", | |
| 88 | + "```", | |
| 89 | + "@decorator", | |
| 90 | + "```", | |
| 91 | + "@~/.ssh/id_rsa is outside the project.", | |
| 92 | + "@../escape.md is too.", | |
| 93 | + "@missing.md is not there.", | |
| 94 | + }, "\n")) | |
| 95 | + write(t, root, "AGENTS.md", "agents body, see @CLAUDE.md") | |
| 96 | + write(t, root, "docs/style.md", "style body @deeper/one.md") | |
| 97 | + write(t, root, "docs/deeper/one.md", "one @two.md") | |
| 98 | + write(t, root, "docs/deeper/two.md", "two @three.md") | |
| 99 | + write(t, root, "docs/deeper/three.md", "three @four.md") | |
| 100 | + write(t, root, "docs/deeper/four.md", "four @five.md") | |
| 101 | + write(t, root, "docs/deeper/five.md", "five — too deep") | |
| 102 | + write(t, root, "inline", "should not load") | |
| 103 | + write(t, root, "decorator", "should not load") | |
| 104 | + write(t, home, ".ssh/id_rsa", "PRIVATE KEY") | |
| 105 | + write(t, home, "src/escape.md", "escaped") | |
| 106 | + | |
| 107 | + files, errs := LoadInstructions(home, root) | |
| 108 | + got := shownOf(files) | |
| 109 | + want := []string{ | |
| 110 | + "CLAUDE.md", | |
| 111 | + "AGENTS.md", | |
| 112 | + "docs/style.md", | |
| 113 | + "docs/deeper/one.md", | |
| 114 | + "docs/deeper/two.md", | |
| 115 | + "docs/deeper/three.md", | |
| 116 | + "docs/deeper/four.md", | |
| 117 | + } | |
| 118 | + if !reflect.DeepEqual(got, want) { | |
| 119 | + t.Errorf("got %q\nwant %q", got, want) | |
| 120 | + } | |
| 121 | + for _, f := range files { | |
| 122 | + if strings.Contains(f.Body, "PRIVATE KEY") || f.Body == "escaped" { | |
| 123 | + t.Errorf("a project file imported %s from outside the project", f.Path) | |
| 124 | + } | |
| 125 | + } | |
| 126 | + if files[1].Role != "imported by CLAUDE.md" { | |
| 127 | + t.Errorf("role %q", files[1].Role) | |
| 128 | + } | |
| 129 | + if len(errs) != 2 { | |
| 130 | + t.Errorf("want the two escapes reported, got %v", errs) | |
| 131 | + } | |
| 132 | + } | |
| 133 | + | |
| 134 | + // The user's own file may import from the home directory: it is theirs. | |
| 135 | + func TestUserInstructionsImportFromHome(t *testing.T) { | |
| 136 | + home, root := dirs(t) | |
| 137 | + write(t, home, UserInstructions, "@~/notes/prefs.md") | |
| 138 | + write(t, home, "notes/prefs.md", "tabs, not spaces") | |
| 139 | + files, _ := LoadInstructions(home, root) | |
| 140 | + if len(files) != 2 || files[1].Body != "tabs, not spaces" { | |
| 141 | + t.Errorf("got %+v", files) | |
| 142 | + } | |
| 143 | + } | |
| 144 | + | |
| 145 | + // A symlink committed to a repository can point anywhere, so the project's | |
| 146 | + // own files are checked where they lead. | |
| 147 | + func TestProjectInstructionSymlinkOut(t *testing.T) { | |
| 148 | + home, root := dirs(t) | |
| 149 | + os.MkdirAll(filepath.Join(root, ".git"), 0o755) | |
| 150 | + secret := write(t, home, ".aws/credentials", "KEY") | |
| 151 | + if err := os.Symlink(secret, filepath.Join(root, "CLAUDE.md")); err != nil { | |
| 152 | + t.Skip(err) | |
| 153 | + } | |
| 154 | + files, _ := LoadInstructions(home, root) | |
| 155 | + if len(files) != 0 { | |
| 156 | + t.Errorf("followed a symlink out of the project: %+v", files) | |
| 157 | + } | |
| 158 | + } | |
| 159 | + | |
| 160 | + func TestFormatInstructions(t *testing.T) { | |
| 161 | + if FormatInstructions(nil) != "" { | |
| 162 | + t.Error("no files should add nothing to the prompt") | |
| 163 | + } | |
| 164 | + got := FormatInstructions([]Instruction{{Shown: "CLAUDE.md", Role: roleProject, Body: "Run make test."}}) | |
| 165 | + for _, want := range []string{"# Instructions from CLAUDE.md", "## CLAUDE.md (this project's instructions, checked in)", "Run make test."} { | |
| 166 | + if !strings.Contains(got, want) { | |
| 167 | + t.Errorf("missing %q in:\n%s", want, got) | |
| 168 | + } | |
| 169 | + } | |
| 170 | + } | |