Vectrum
Sign In

agent: CLAUDE.md, loaded where Claude Code would load it

~/.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>
Jjonicommitted 1 parent 3944649commit2b4f2e7

Showing 4 changed files with 442 additions and 0 deletions.

+8−0internal/agent/loop.goView File
@@ -321,6 +321,13 @@ type Options struct {
321321 NoCache bool
322322 // Stream uses the SSE endpoint so output appears as it is produced.
323323 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
324331 // Skills are the instruction sets discovered at startup. Their names and
325332 // descriptions are appended to the system prompt; the bodies are loaded on
326333 // demand by the skill tool.
@@ -553,6 +560,7 @@ func New(client *llm.Client, reg *tools.Registry, state *State, log *metrics.Log
553560 if opts.System == "" {
554561 opts.System = defaultSystem
555562 }
563+ opts.System += opts.Instructions
556564 opts.System += skillListing(opts.Skills)
557565 opts.System += modelListing(opts.Models)
558566 return &Agent{client: client, tools: reg, state: state, log: log, opts: opts}
+17−0internal/agent/loop_test.goView File
@@ -815,6 +815,23 @@ func TestSystemPromptListsSkills(t *testing.T) {
815815 }
816816 }
817817
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+
818835 func TestSystemPromptUnchangedWithoutSkills(t *testing.T) {
819836 a := New(nil, tools.NewRegistry(nil), NewState(), nil, Options{})
820837 if a.opts.System != defaultSystem {
+247−0internal/ccsettings/instructions.goaddedView File
@@ -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+ }
+170−0internal/ccsettings/instructions_test.goaddedView File
@@ -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+ }