diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7cbe744..8df6de6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -175,4 +175,4 @@ patterns = ["contributing", "contributing.md", "contributing.txt", "contributing dirs = ["docs", ".github", ".gitlab"] ``` -`field` is the JSON key the path is written to. `group` places it under one of `legal`, `community`, `security`, or `metadata`; omit it for top-level fields like `readme` and `license`. `patterns` are matched case-insensitively against directory listings, so list each pattern once in lowercase. List explicit extensions rather than a trailing glob for prose files so `support.md` matches but `docs/Support-Tiers.md` does not. `dirs` lists extra directories to search after the repo root; root always wins on a tie. +`field` is the JSON key the path is written to. `group` places it under one of `legal`, `community`, `security`, `metadata`, or `agents`; omit it for top-level fields like `readme` and `license`. `patterns` are matched case-insensitively against directory listings, so list each pattern once in lowercase. List explicit extensions rather than a trailing glob for prose files so `support.md` matches but `docs/Support-Tiers.md` does not. `dirs` lists extra directories to search after the repo root; root always wins on a tie. diff --git a/README.md b/README.md index e5f6757..7b7b375 100644 --- a/README.md +++ b/README.md @@ -243,9 +243,9 @@ Data sources: [ecosyste.ms](https://ecosyste.ms) for published package metadata, ## Resources -Alongside the toolchain, brief picks up the conventional documents that describe how a project is run. README, changelog, roadmap, license (with detected SPDX identifier), and agent instructions are reported at the top level. Everything else is grouped: legal covers copyright, NOTICE, DCO, and CLA files; community covers contributing guides, code of conduct, support, governance, maintainers, authors, CODEOWNERS, and DEI statements; security covers the security policy, threat model, and audit reports; metadata covers machine-readable files like FUNDING, CITATION.cff, publiccode.yml, codemeta.json, and .zenodo.json. +Alongside the toolchain, brief picks up the conventional documents that describe how a project is run. README, changelog, roadmap, and license (with detected SPDX identifier) are reported at the top level. Everything else is grouped: legal covers copyright, NOTICE, DCO, and CLA files; community covers contributing guides, code of conduct, support, governance, maintainers, authors, CODEOWNERS, and DEI statements; security covers the security policy, threat model, and audit reports; metadata covers machine-readable files like FUNDING, CITATION.cff, publiccode.yml, codemeta.json, and .zenodo.json; agents covers AI coding tool instruction files including AGENTS.md, CLAUDE.md, GEMINI.md, .cursorrules, .windsurfrules, .github/copilot-instructions.md, .aider.conf.yml, .clinerules, .rules, .junie/guidelines.md, replit.md, .continuerules, .augment-guidelines, and .roorules. -Matching is case-insensitive and checks the repo root first, then `docs/`, `.github/`, and `.gitlab/`. Paths in the output are repo-relative, so a funding file found under `.github/` is reported as `.github/FUNDING.yml` rather than just the basename. In JSON the groups appear as nested objects under `resources.legal`, `resources.community`, `resources.security`, and `resources.metadata`. +Matching is case-insensitive and checks the repo root first, then `docs/`, `.github/`, and `.gitlab/`. Paths in the output are repo-relative, so a funding file found under `.github/` is reported as `.github/FUNDING.yml` rather than just the basename. In JSON the groups appear as nested objects under `resources.legal`, `resources.community`, `resources.security`, `resources.metadata`, and `resources.agents`. Issue and pull request templates are reported under `resources.templates` so that contributors and coding agents can find and follow them. Unlike the single-path resources above this is a list: every template file found is included, split into `issue` and `pull_request` arrays plus a `config` path for the issue chooser. Detection covers the locations recognised by GitHub, GitLab, Gitea, and Forgejo, which means the repo root, `docs/`, `.github/`, `.gitea/`, `.forgejo/`, and `.gitlab/`, in both single-file form (`PULL_REQUEST_TEMPLATE.md`, `issue_template.md`) and directory form (`ISSUE_TEMPLATE/`, `PULL_REQUEST_TEMPLATE/`, GitLab's `issue_templates/` and `merge_request_templates/`). Merge request templates are reported under `pull_request` rather than getting their own field. diff --git a/brief.go b/brief.go index a19f930..27dd379 100644 --- a/brief.go +++ b/brief.go @@ -107,12 +107,12 @@ type ResourceInfo struct { Roadmap string `json:"roadmap,omitempty"` License string `json:"license,omitempty"` LicenseType string `json:"license_type,omitempty"` - Agents string `json:"agents,omitempty"` Legal map[string]string `json:"legal,omitempty"` Community map[string]string `json:"community,omitempty"` Security map[string]string `json:"security,omitempty"` Metadata map[string]string `json:"metadata,omitempty"` + Agents map[string]string `json:"agents,omitempty"` Templates *TemplateInfo `json:"templates,omitempty"` } @@ -155,6 +155,11 @@ func (r *ResourceInfo) Group(name string) map[string]string { r.Metadata = map[string]string{} } return r.Metadata + case "agents": + if r.Agents == nil { + r.Agents = map[string]string{} + } + return r.Agents } return nil } @@ -162,10 +167,10 @@ func (r *ResourceInfo) Group(name string) map[string]string { // Empty reports whether no resources were found. func (r *ResourceInfo) Empty() bool { return r.Readme == "" && r.Changelog == "" && r.Roadmap == "" && - r.License == "" && r.Agents == "" && + r.License == "" && len(r.Legal) == 0 && len(r.Community) == 0 && len(r.Security) == 0 && len(r.Metadata) == 0 && - r.Templates.Empty() + len(r.Agents) == 0 && r.Templates.Empty() } // Skill is an agent skill the project provides: packaged instructions an AI diff --git a/detect/detect.go b/detect/detect.go index 699ad12..c781499 100644 --- a/detect/detect.go +++ b/detect/detect.go @@ -1202,8 +1202,6 @@ func (e *Engine) detectResources() *brief.ResourceInfo { case "license": res.License = rel res.LicenseType = detectLicenseType(abs) - case "agents": - res.Agents = rel } } diff --git a/detect/detect_test.go b/detect/detect_test.go index 807e31b..caf5a18 100644 --- a/detect/detect_test.go +++ b/detect/detect_test.go @@ -156,8 +156,8 @@ func TestResourceGroups(t *testing.T) { if res.Readme != "README.md" { t.Errorf("readme = %q", res.Readme) } - if res.Agents != "AGENTS.md" { - t.Errorf("agents = %q", res.Agents) + if res.Agents["agents"] != "AGENTS.md" { + t.Errorf("agents.agents = %q", res.Agents["agents"]) } if res.Legal["notice"] != "NOTICE" { t.Errorf("legal.notice = %q", res.Legal["notice"]) @@ -303,6 +303,89 @@ func writeFile(t *testing.T, dir, p, content string) { } } +func TestResourceAgents(t *testing.T) { + dir := t.TempDir() + touch := func(p string) { + full := filepath.Join(dir, p) + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + touch("AGENTS.md") + touch("CLAUDE.md") + touch("GEMINI.md") + touch(".cursorrules") + touch(".windsurfrules") + touch(".github/copilot-instructions.md") + touch(".aider.conf.yml") + touch(".clinerules") + touch(".rules") + touch(".junie/guidelines.md") + touch("replit.md") + touch(".continuerules") + touch(".augment-guidelines") + touch(".roorules") + + engine := New(loadKB(t), dir) + r, err := engine.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + if r.Resources == nil { + t.Fatal("expected resources") + } + want := map[string]string{ + "agents": "AGENTS.md", + "claude": "CLAUDE.md", + "gemini": "GEMINI.md", + "cursor": ".cursorrules", + "windsurf": ".windsurfrules", + "copilot": ".github/copilot-instructions.md", + "aider": ".aider.conf.yml", + "cline": ".clinerules", + "zed": ".rules", + "junie": ".junie/guidelines.md", + "replit": "replit.md", + "continue": ".continuerules", + "augment": ".augment-guidelines", + "roo": ".roorules", + } + for field, path := range want { + if got := r.Resources.Agents[field]; got != path { + t.Errorf("agents.%s = %q, want %q", field, got, path) + } + } + if len(r.Resources.Agents) != len(want) { + t.Errorf("agents has %d entries, want %d: %v", len(r.Resources.Agents), len(want), r.Resources.Agents) + } +} + +func TestResourceAgentsCursorRulesDir(t *testing.T) { + dir := t.TempDir() + full := filepath.Join(dir, ".cursor", "rules", "general.mdc") + if err := os.MkdirAll(filepath.Dir(full), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(full, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + engine := New(loadKB(t), dir) + r, err := engine.Run() + if err != nil { + t.Fatalf("Run: %v", err) + } + if r.Resources == nil { + t.Fatal("expected resources") + } + if got := r.Resources.Agents["cursor"]; got != ".cursor/rules/general.mdc" { + t.Errorf("agents.cursor = %q", got) + } +} + func TestDetectSkills(t *testing.T) { dir := t.TempDir() writeFile(t, dir, "skills/pdf/SKILL.md", `--- diff --git a/detect/filter.go b/detect/filter.go index e45b386..fef0cbf 100644 --- a/detect/filter.go +++ b/detect/filter.go @@ -194,9 +194,6 @@ func (fc *filterContext) filterResources(res *brief.ResourceInfo, changedFiles [ out.License = res.License out.LicenseType = res.LicenseType } - if hit(res.Agents) { - out.Agents = res.Agents - } filterGroup := func(name string, in map[string]string) { for k, v := range in { if hit(v) { @@ -208,6 +205,7 @@ func (fc *filterContext) filterResources(res *brief.ResourceInfo, changedFiles [ filterGroup("community", res.Community) filterGroup("security", res.Security) filterGroup("metadata", res.Metadata) + filterGroup("agents", res.Agents) if t := res.Templates; t != nil { ft := &brief.TemplateInfo{} diff --git a/detect/filter_test.go b/detect/filter_test.go index 87b62ae..882308c 100644 --- a/detect/filter_test.go +++ b/detect/filter_test.go @@ -18,9 +18,13 @@ func TestFilterResources(t *testing.T) { Metadata: map[string]string{ "funding": ".github/FUNDING.yml", }, + Agents: map[string]string{ + "agents": "AGENTS.md", + "claude": "CLAUDE.md", + }, } fc := &filterContext{} - out := fc.filterResources(res, []string{"README.md", ".github/FUNDING.yml"}) + out := fc.filterResources(res, []string{"README.md", ".github/FUNDING.yml", "CLAUDE.md"}) if out == nil { t.Fatal("expected filtered resources") } @@ -36,6 +40,12 @@ func TestFilterResources(t *testing.T) { if out.Metadata["funding"] != ".github/FUNDING.yml" { t.Errorf("metadata.funding = %q", out.Metadata["funding"]) } + if out.Agents["claude"] != "CLAUDE.md" { + t.Errorf("agents.claude = %q", out.Agents["claude"]) + } + if _, ok := out.Agents["agents"]; ok { + t.Errorf("agents.agents should be filtered out, got %v", out.Agents) + } if fc.filterResources(res, []string{"main.go"}) != nil { t.Error("expected nil when no resources changed") diff --git a/kb/kb.go b/kb/kb.go index e8f8656..345282a 100644 --- a/kb/kb.go +++ b/kb/kb.go @@ -149,7 +149,7 @@ type ResourceDef struct { type ResourceInfo struct { Name string `toml:"name"` Field string `toml:"field"` // JSON field name in output - Group string `toml:"group"` // optional group: legal, community, security, metadata + Group string `toml:"group"` // optional group: legal, community, security, metadata, agents Patterns []string `toml:"patterns"` // file patterns to match Dirs []string `toml:"dirs"` // additional subdirectories to search (root is always searched first) } diff --git a/knowledge/_shared/_resources.toml b/knowledge/_shared/_resources.toml index 8790d14..377bb9a 100644 --- a/knowledge/_shared/_resources.toml +++ b/knowledge/_shared/_resources.toml @@ -29,13 +29,6 @@ name = "License" field = "license" patterns = ["license*", "licence*", "copying*", "mit-license*"] -[[resources]] -[resources.resource] -name = "Agents" -field = "agents" -patterns = ["agents.md"] -dirs = ["docs", ".github", ".gitlab"] - # Legal [[resources]] @@ -195,3 +188,107 @@ name = "Zenodo" field = "zenodo" group = "metadata" patterns = [".zenodo.json"] + +# Agents (AI coding tool instruction files) + +[[resources]] +[resources.resource] +name = "Agents" +field = "agents" +group = "agents" +patterns = ["agents.md", "agent.md"] +dirs = ["docs", ".github", ".gitlab"] + +[[resources]] +[resources.resource] +name = "Claude Code" +field = "claude" +group = "agents" +patterns = ["claude.md"] + +[[resources]] +[resources.resource] +name = "Gemini CLI" +field = "gemini" +group = "agents" +patterns = ["gemini.md"] + +[[resources]] +[resources.resource] +name = "Cursor" +field = "cursor" +group = "agents" +patterns = [".cursorrules", "*.mdc"] +dirs = [".cursor/rules"] + +[[resources]] +[resources.resource] +name = "Windsurf" +field = "windsurf" +group = "agents" +patterns = [".windsurfrules"] + +[[resources]] +[resources.resource] +name = "GitHub Copilot" +field = "copilot" +group = "agents" +patterns = ["copilot-instructions.md"] +dirs = [".github"] + +[[resources]] +[resources.resource] +name = "Aider" +field = "aider" +group = "agents" +patterns = [".aider.conf.yml", "conventions.md"] + +[[resources]] +[resources.resource] +name = "Cline" +field = "cline" +group = "agents" +patterns = [".clinerules"] + +[[resources]] +[resources.resource] +name = "Zed" +field = "zed" +group = "agents" +patterns = [".rules"] + +[[resources]] +[resources.resource] +name = "Junie" +field = "junie" +group = "agents" +patterns = ["guidelines.md"] +dirs = [".junie"] + +[[resources]] +[resources.resource] +name = "Replit" +field = "replit" +group = "agents" +patterns = ["replit.md"] + +[[resources]] +[resources.resource] +name = "Continue" +field = "continue" +group = "agents" +patterns = [".continuerules"] + +[[resources]] +[resources.resource] +name = "Augment" +field = "augment" +group = "agents" +patterns = [".augment-guidelines"] + +[[resources]] +[resources.resource] +name = "Roo Code" +field = "roo" +group = "agents" +patterns = [".roorules"] diff --git a/report/markdown.go b/report/markdown.go index 2ae0a39..dd086de 100644 --- a/report/markdown.go +++ b/report/markdown.go @@ -260,11 +260,11 @@ func mdResources(w io.Writer, res *brief.ResourceInfo) { } _, _ = fmt.Fprintf(w, "- %s\n", label) } - mdResource(w, res.Agents) mdResourceGroup(w, "Legal", res.Legal) mdResourceGroup(w, "Community", res.Community) mdResourceGroup(w, "Security", res.Security) mdResourceGroup(w, "Metadata", res.Metadata) + mdResourceGroup(w, "Agents", res.Agents) mdTemplates(w, res.Templates) } diff --git a/report/markdown_test.go b/report/markdown_test.go index 6868fea..ea23997 100644 --- a/report/markdown_test.go +++ b/report/markdown_test.go @@ -168,6 +168,9 @@ func TestMarkdownResources(t *testing.T) { Metadata: map[string]string{ "funding": ".github/FUNDING.yml", }, + Agents: map[string]string{ + "claude": "CLAUDE.md", + }, }, } @@ -190,6 +193,9 @@ func TestMarkdownResources(t *testing.T) { if !strings.Contains(out, "- Metadata:\n - .github/FUNDING.yml") { t.Errorf("missing metadata group\ngot:\n%s", out) } + if !strings.Contains(out, "- Agents:\n - CLAUDE.md") { + t.Errorf("missing agents group\ngot:\n%s", out) + } } func TestMarkdownTemplates(t *testing.T) { diff --git a/report/report.go b/report/report.go index 8bf5652..33b9c20 100644 --- a/report/report.go +++ b/report/report.go @@ -295,11 +295,11 @@ func printResources(w io.Writer, res *brief.ResourceInfo) { } _, _ = fmt.Fprintf(w, "Resources: %s\n", label) } - printResource(w, res.Agents) printResourceGroup(w, "Legal", res.Legal) printResourceGroup(w, "Community", res.Community) printResourceGroup(w, "Security", res.Security) printResourceGroup(w, "Metadata", res.Metadata) + printResourceGroup(w, "Agents", res.Agents) printTemplates(w, res.Templates) }