feat(config): load user-level context files

Amolith created

Load ~/.config/crush/CRUSH.md and ~/.config/AGENTS.md by default as
global context files, separate from project context. Global context is
presented to the model as user preferences that apply across all
projects. Paths are configurable via global_context_paths.

Change summary

README.md                             | 27 +++++++++++++++
internal/agent/common_test.go         |  1 
internal/agent/prompt/prompt.go       | 51 +++++++++++++++++-----------
internal/agent/templates/coder.md.tpl | 18 +++++++++-
internal/config/config.go             |  1 
internal/config/load.go               | 11 ++++++
schema.json                           | 11 ++++++
7 files changed, 97 insertions(+), 23 deletions(-)

Detailed changes

README.md 🔗

@@ -402,6 +402,33 @@ down. There is a short grace window right after `POST /v1/workspaces` so a
 client that has created the workspace but not yet opened its event stream
 does not get reaped before it can attach.
 
+### Global context files
+
+Crush automatically includes two files for cross-project instructions.
+
+- `~/.config/crush/CRUSH.md`: Crush-specific rules that would confuse other
+  agentic coding tools. If you only use Crush, this is the only one you need to
+  edit.
+- `~/.config/AGENTS.md`: generic instructions that other coding tools might
+  read. Avoid referring to Crush-specific features or workflows here. You
+  probably only care about this if you use multiple agentic coding tools and
+  want to share instructions between them.
+
+You can customize these paths using the `global_context_paths` option in your
+configuration:
+
+```jsonc
+{
+  "$schema": "https://charm.land/crush.json",
+  "options": {
+    "global_context_paths": [
+      "~/path/to/custom/context/file.md",
+      "/full/path/to/folder/of/files/" // recursively load all .md files in folder
+    ]
+  }
+}
+```
+
 ### Ignoring Files
 
 Crush respects `.gitignore` files by default, but you can also create a

internal/agent/common_test.go 🔗

@@ -152,6 +152,7 @@ func coderAgent(r *vcr.Recorder, env fakeEnv, large, small fantasy.LanguageModel
 	cfg.Config().Options.SkillsPaths = nil
 	cfg.Config().Options.DisabledSkills = []string{"crush-config"}
 	cfg.Config().Options.ContextPaths = nil
+	cfg.Config().Options.GlobalContextPaths = nil
 	cfg.Config().LSP = nil
 
 	systemPrompt, err := prompt.Build(context.TODO(), large.Provider(), large.Model(), cfg)

internal/agent/prompt/prompt.go 🔗

@@ -29,16 +29,17 @@ type Prompt struct {
 }
 
 type PromptDat struct {
-	Provider      string
-	Model         string
-	Config        config.Config
-	WorkingDir    string
-	IsGitRepo     bool
-	Platform      string
-	Date          string
-	GitStatus     string
-	ContextFiles  []ContextFile
-	AvailSkillXML string
+	Provider           string
+	Model              string
+	Config             config.Config
+	WorkingDir         string
+	IsGitRepo          bool
+	Platform           string
+	Date               string
+	GitStatus          string
+	ContextFiles       []ContextFile
+	GlobalContextFiles []ContextFile
+	AvailSkillXML      string
 }
 
 type ContextFile struct {
@@ -147,22 +148,27 @@ func expandPath(path string, store *config.ConfigStore) string {
 	return path
 }
 
-func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) {
-	workingDir := cmp.Or(p.workingDir, store.WorkingDir())
-	platform := cmp.Or(p.platform, runtime.GOOS)
-
+// loadContextFiles loads and deduplicates context files from a list of paths.
+func loadContextFiles(paths []string, store *config.ConfigStore) map[string][]ContextFile {
 	files := map[string][]ContextFile{}
-
-	cfg := store.Config()
-	for _, pth := range cfg.Options.ContextPaths {
+	for _, pth := range paths {
 		expanded := expandPath(pth, store)
 		pathKey := strings.ToLower(expanded)
 		if _, ok := files[pathKey]; ok {
 			continue
 		}
-		content := processContextPath(expanded, store)
-		files[pathKey] = content
+		files[pathKey] = processContextPath(expanded, store)
 	}
+	return files
+}
+
+func (p *Prompt) promptData(ctx context.Context, provider, model string, store *config.ConfigStore) (PromptDat, error) {
+	workingDir := cmp.Or(p.workingDir, store.WorkingDir())
+	platform := cmp.Or(p.platform, runtime.GOOS)
+
+	cfg := store.Config()
+	contextFiles := loadContextFiles(cfg.Options.ContextPaths, store)
+	globalContextFiles := loadContextFiles(cfg.Options.GlobalContextPaths, store)
 
 	// Discover and load skills metadata.
 	var availSkillXML string
@@ -217,8 +223,11 @@ func (p *Prompt) promptData(ctx context.Context, provider, model string, store *
 		}
 	}
 
-	for _, contextFiles := range files {
-		data.ContextFiles = append(data.ContextFiles, contextFiles...)
+	for _, files := range contextFiles {
+		data.ContextFiles = append(data.ContextFiles, files...)
+	}
+	for _, files := range globalContextFiles {
+		data.GlobalContextFiles = append(data.GlobalContextFiles, files...)
 	}
 	return data, nil
 }

internal/agent/templates/coder.md.tpl 🔗

@@ -396,11 +396,25 @@ If a skill mentions scripts, references, or assets, they live in the same folder
 {{end}}
 
 {{if .ContextFiles}}
-<memory>
+# Project-Specific Context
+Make sure to follow the instructions in the context below.
+<project_context>
 {{range .ContextFiles}}
 <file path="{{.Path}}">
 {{.Content}}
 </file>
 {{end}}
-</memory>
+</project_context>
+{{end}}
+{{if .GlobalContextFiles}}
+
+# User context
+The following is personal content added by the user that they'd like you to follow no matter what project you're working in.
+<user_preferences>
+{{range .GlobalContextFiles}}
+<file path="{{.Path}}">
+{{.Content}}
+</file>
+{{end}}
+</user_preferences>
 {{end}}

internal/config/config.go 🔗

@@ -260,6 +260,7 @@ func (Attribution) JSONSchemaExtend(schema *jsonschema.Schema) {
 
 type Options struct {
 	ContextPaths         []string    `json:"context_paths,omitempty" jsonschema:"description=Paths to files containing context information for the AI,example=.cursorrules,example=CRUSH.md"`
+	GlobalContextPaths   []string    `json:"global_context_paths,omitempty" jsonschema:"description=Paths to files containing global context information for the AI,default=~/.config/crush/CRUSH.md,default=~/.config/AGENTS.md"`
 	SkillsPaths          []string    `json:"skills_paths,omitempty" jsonschema:"description=Paths to directories containing Agent Skills (folders with SKILL.md files),example=~/.config/crush/skills,example=./skills"`
 	TUI                  *TUIOptions `json:"tui,omitempty" jsonschema:"description=Terminal user interface options"`
 	Debug                bool        `json:"debug,omitempty" jsonschema:"description=Enable debug logging,default=false"`

internal/config/load.go 🔗

@@ -417,6 +417,16 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 	if c.Options.TUI == nil {
 		c.Options.TUI = &TUIOptions{}
 	}
+	if len(c.Options.GlobalContextPaths) == 0 {
+		crushConfigDir := filepath.Dir(GlobalConfig())
+		c.Options.GlobalContextPaths = []string{
+			filepath.Join(crushConfigDir, "CRUSH.md"),
+			filepath.Join(filepath.Dir(crushConfigDir), "AGENTS.md"),
+		}
+	}
+	slices.Sort(c.Options.GlobalContextPaths)
+	c.Options.GlobalContextPaths = slices.Compact(c.Options.GlobalContextPaths)
+
 	if dataDir != "" {
 		c.Options.DataDirectory = dataDir
 	} else if c.Options.DataDirectory == "" {
@@ -448,6 +458,7 @@ func (c *Config) setDefaults(workingDir, dataDir string) {
 
 	// Add the default context paths if they are not already present
 	c.Options.ContextPaths = append(slices.Clone(defaultContextPaths), c.Options.ContextPaths...)
+
 	slices.Sort(c.Options.ContextPaths)
 	c.Options.ContextPaths = slices.Compact(c.Options.ContextPaths)
 

schema.json 🔗

@@ -405,6 +405,17 @@
           "type": "array",
           "description": "Paths to files containing context information for the AI"
         },
+        "global_context_paths": {
+          "items": {
+            "type": "string"
+          },
+          "type": "array",
+          "description": "Paths to files containing global context information for the AI",
+          "default": [
+            "~/.config/crush/CRUSH.md",
+            "~/.config/AGENTS.md"
+          ]
+        },
         "skills_paths": {
           "items": {
             "type": "string",