diff --git a/CHANGELOG.md b/CHANGELOG.md index 375f1c7..f070216 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ and [`agents/peggy/CHANGELOG.md`](agents/peggy/CHANGELOG.md). ## Unreleased +- **AGENTS.md is now a chain from repo root to workdir + (`LoadContext`).** Previously only `/AGENTS.md` loaded; + in a monorepo, running from a subdirectory lost the repo-wide + instructions (or vice versa). Every AGENTS.md from the repository + root (nearest ancestor with `.git`) down to the workdir is + concatenated root-first, so nearer files read as refinements. + Outside a repository the old single-file behavior is unchanged. + (#369) + - **Actionable model-unavailable errors (`providers.ModelUnavailableHint`, `Factory.ModelsListURL`).** A 404 / model-not-found failure previously surfaced as a raw provider diff --git a/context.go b/context.go index c9a3f92..a185365 100644 --- a/context.go +++ b/context.go @@ -38,20 +38,28 @@ type ProjectContext struct { Roles map[string]Role } -// LoadContext loads AGENTS.md (non-fatal if missing) and skills under -// `/.agents/skills//SKILL.md`. An empty workDir returns an -// empty context. +// LoadContext loads the AGENTS.md chain (non-fatal if missing) and +// skills under `/.agents/skills//SKILL.md`. An empty +// workDir returns an empty context. +// +// AGENTS.md is a chain, not a single file: every AGENTS.md from the +// repository root (the nearest ancestor containing `.git`, workDir +// included) down to workDir is concatenated, root first and nearest +// last — so in a monorepo a subdirectory can add local instructions on +// top of the repo-wide ones, and later (nearer) files read naturally +// as refinements. Outside a repository only `/AGENTS.md` is +// consulted, matching the old single-file behavior. func LoadContext(workDir string) (ProjectContext, error) { var ctx ProjectContext if strings.TrimSpace(workDir) == "" { return ctx, nil } - if data, err := os.ReadFile(filepath.Join(workDir, "AGENTS.md")); err == nil { - ctx.AgentsMD = strings.TrimSpace(string(data)) - } else if !errors.Is(err, os.ErrNotExist) { + agentsMD, err := loadAgentsMDChain(workDir) + if err != nil { return ProjectContext{}, err } + ctx.AgentsMD = agentsMD skills, err := loadSkills(filepath.Join(workDir, ".agents", "skills")) if err != nil { @@ -67,6 +75,59 @@ func LoadContext(workDir string) (ProjectContext, error) { return ctx, nil } +// loadAgentsMDChain concatenates every AGENTS.md from the repo root +// down to workDir (root first). Missing files are skipped; read errors +// other than not-exist propagate. +func loadAgentsMDChain(workDir string) (string, error) { + abs, err := filepath.Abs(workDir) + if err != nil { + return "", err + } + + // The chain's top is the nearest ancestor (workDir included) that + // contains .git; without one the chain is workDir alone. + top := abs + for dir := abs; ; { + if _, err := os.Stat(filepath.Join(dir, ".git")); err == nil { + top = dir + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + var chain []string + for dir := abs; ; { + chain = append([]string{dir}, chain...) + if dir == top { + break + } + parent := filepath.Dir(dir) + if parent == dir { + break + } + dir = parent + } + + var parts []string + for _, dir := range chain { + data, err := os.ReadFile(filepath.Join(dir, "AGENTS.md")) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return "", err + } + if text := strings.TrimSpace(string(data)); text != "" { + parts = append(parts, text) + } + } + return strings.Join(parts, "\n\n"), nil +} + func loadRoles(rolesDir string) (map[string]Role, error) { entries, err := os.ReadDir(rolesDir) if errors.Is(err, os.ErrNotExist) { diff --git a/context_test.go b/context_test.go index e86660b..430455f 100644 --- a/context_test.go +++ b/context_test.go @@ -224,3 +224,61 @@ func TestSessionSkillProgrammaticEntryWins(t *testing.T) { t.Fatalf("user = %q, want programmatic 'from code' to win over disk", user) } } + +func TestLoadContextAgentsMDChain(t *testing.T) { + root := t.TempDir() + sub := filepath.Join(root, "services", "api") + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(sub, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "AGENTS.md"), []byte("repo-wide rules"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(sub, "AGENTS.md"), []byte("api-local rules"), 0o644); err != nil { + t.Fatal(err) + } + + ctx, err := LoadContext(sub) + if err != nil { + t.Fatalf("LoadContext: %v", err) + } + if ctx.AgentsMD != "repo-wide rules\n\napi-local rules" { + t.Fatalf("AgentsMD = %q, want root-first concatenation", ctx.AgentsMD) + } + + // Intermediate directory without AGENTS.md is skipped silently; + // a workDir at the repo root sees only the root file. + ctx, err = LoadContext(root) + if err != nil { + t.Fatalf("LoadContext(root): %v", err) + } + if ctx.AgentsMD != "repo-wide rules" { + t.Fatalf("AgentsMD at root = %q", ctx.AgentsMD) + } +} + +func TestLoadContextAgentsMDNoRepo(t *testing.T) { + // No .git anywhere in the temp tree: chain is workDir alone, so a + // parent AGENTS.md must NOT leak in. + parent := t.TempDir() + work := filepath.Join(parent, "w") + if err := os.MkdirAll(work, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(parent, "AGENTS.md"), []byte("parent rules"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, "AGENTS.md"), []byte("local rules"), 0o644); err != nil { + t.Fatal(err) + } + ctx, err := LoadContext(work) + if err != nil { + t.Fatalf("LoadContext: %v", err) + } + if ctx.AgentsMD != "local rules" { + t.Fatalf("AgentsMD = %q, want only the local file outside a repo", ctx.AgentsMD) + } +}