feat(discovery): frontmatter guard for flat agents/commands docs

A flat .md under agents/ or commands/ whose frontmatter contains none of
the definition keys (name, description, mode, agent, model,
argument-hint, temperature, permission) is documentation, not an item:
discovery marks it Skipped with a recorded warning — it never enters a
selection, inventory, availability listing or collision index, but the
warning lands in the lockfile and add/update/select reports like other
discovery warnings. Skills keep their folder+SKILL.md marker, MCP keeps
the .yaml marker. README gains the canonical layout table and the
migration note for folder-style agent repos.

Closes beads: agent-lib-2n7
This commit is contained in:
2026-08-23 15:17:46 +02:00
parent 876ff1c6b9
commit 875a3c8aae
8 changed files with 269 additions and 14 deletions
+11
View File
@@ -36,6 +36,11 @@ type Item struct {
RelPath string
Frontmatter *Frontmatter
Warnings []string
// Skipped marks a file discovery recognized but deliberately excluded —
// e.g. a documentation .md under agents/ or commands/ without definition
// frontmatter. Skipped items carry warnings but must never enter a
// selection, inventory or availability listing.
Skipped bool
}
// Scan discovers items of all four content types under cfg. Discovery is a
@@ -156,6 +161,12 @@ func scanFlatFiles(tree Tree, fileEntries map[string]map[string]bool, root, typ
continue
}
it := Item{Type: typ, UpstreamID: id, RelPath: rel}
if !HasDefinitionFrontmatter(data) {
it.Skipped = true
it.Warnings = append(it.Warnings, fmt.Sprintf("%s: no %s frontmatter, skipped", rel, strings.TrimSuffix(typ, "s")))
items = append(items, it)
continue
}
fm, warnings := ParseFrontmatter(data)
it.Frontmatter = &fm
for _, w := range warnings {
+49 -8
View File
@@ -20,16 +20,11 @@ type Frontmatter struct {
func ParseFrontmatter(data []byte) (Frontmatter, []string) {
var fm Frontmatter
var warnings []string
lines := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
if len(lines) == 0 || strings.TrimSpace(lines[0]) != "---" {
lines, fenced, closed := parseFence(data)
if !fenced {
return fm, nil
}
closed := false
for _, line := range lines[1:] {
if strings.TrimSpace(line) == "---" {
closed = true
break
}
for _, line := range lines {
trimmed := strings.TrimSpace(line)
if trimmed == "" {
continue
@@ -58,6 +53,52 @@ func ParseFrontmatter(data []byte) (Frontmatter, []string) {
return fm, warnings
}
// definitionKeys mark a flat .md under agents/ or commands/ as an intentional
// agent/command definition rather than dropped documentation.
var definitionKeys = map[string]bool{
"name": true,
"description": true,
"mode": true,
"agent": true,
"model": true,
"argument-hint": true,
"temperature": true,
"permission": true,
}
// HasDefinitionFrontmatter reports whether data's leading frontmatter block
// contains any key that marks a flat .md as an agent/command definition.
func HasDefinitionFrontmatter(data []byte) bool {
lines, fenced, _ := parseFence(data)
if !fenced {
return false
}
for _, line := range lines {
key, _, ok := strings.Cut(strings.TrimSpace(line), ":")
if ok && definitionKeys[strings.ToLower(strings.TrimSpace(key))] {
return true
}
}
return false
}
// parseFence returns the lines between the leading `---` fences: the block
// lines, whether the file is fenced at all, and whether the fence closes.
func parseFence(data []byte) (lines []string, fenced, closed bool) {
all := strings.Split(strings.ReplaceAll(string(data), "\r\n", "\n"), "\n")
if len(all) == 0 || strings.TrimSpace(all[0]) != "---" {
return nil, false, false
}
fenced = true
for _, line := range all[1:] {
if strings.TrimSpace(line) == "---" {
return lines, fenced, true
}
lines = append(lines, line)
}
return lines, fenced, false
}
func splitList(v string) []string {
if v == "" {
return nil
+81
View File
@@ -0,0 +1,81 @@
package discovery
import (
"strings"
"testing"
)
func TestHasDefinitionFrontmatter(t *testing.T) {
cases := []struct {
name string
in string
want bool
}{
{name: "no frontmatter at all", in: "# Schema docs\nplain documentation\n", want: false},
{name: "empty fence", in: "---\n---\nbody\n", want: false},
{name: "only unknown keys", in: "---\ntitle: Schema\nlayout: docs\n---\n", want: false},
{name: "name key", in: "---\nname: Scout\n---\nbody\n", want: true},
{name: "description key", in: "---\ndescription: scouts things\n---\n", want: true},
{name: "mode key", in: "---\nmode: primary\n---\n", want: true},
{name: "model key", in: "---\nmodel: sonnet\n---\n", want: true},
{name: "argument-hint key", in: "---\nargument-hint: [msg]\n---\n", want: true},
{name: "temperature key", in: "---\ntemperature: 0.2\n---\n", want: true},
{name: "permission key", in: "---\npermission: edit\n---\n", want: true},
{name: "mixed unknown and known", in: "---\ntitle: X\ndescription: real agent\n---\n", want: true},
{name: "unclosed block still counts keys", in: "---\nname: Broken\n", want: true},
{name: "case-insensitive keys", in: "---\nDescription: Y\n---\n", want: true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := HasDefinitionFrontmatter([]byte(tc.in)); got != tc.want {
t.Errorf("HasDefinitionFrontmatter(%q) = %v, want %v", tc.in, got, tc.want)
}
})
}
}
func TestScanSkipsDocMarkdownInFlatDirs(t *testing.T) {
dir := t.TempDir()
writeTree(t, dir, map[string]string{
"agents/scout.md": "---\ndescription: a real agent\n---\nbody\n",
"agents/SCHEMA.md": "# Schema\npure documentation, no frontmatter\n",
"commands/review.md": "---\nargument-hint: [files]\n---\nbody\n",
"commands/README.md": "Documentation for the commands directory.\n",
"commands/notes.md": "---\ntitle: Notes\n---\nunknown keys only\n",
"skills/good/SKILL.md": "---\nname: Good\n---\n# Good\n",
})
items, err := Scan(&FsTree{Root: dir}, Config{})
if err != nil {
t.Fatal(err)
}
got := map[string]bool{}
skippedWarnings := []string{}
for _, it := range items {
if it.Skipped {
skippedWarnings = append(skippedWarnings, it.Warnings...)
continue
}
got[it.Type+"/"+it.UpstreamID] = true
}
for _, want := range []string{"agents/scout", "commands/review", "skills/good"} {
if !got[want] {
t.Errorf("%s must still be discovered; got %v", want, got)
}
}
for _, doc := range []string{"agents/SCHEMA", "commands/README", "commands/notes"} {
if got[doc] {
t.Errorf("%s is documentation and must be skipped; got %v", doc, got)
}
}
joined := strings.Join(skippedWarnings, "\n")
for _, want := range []string{
"agents/SCHEMA.md: no agent frontmatter, skipped",
"commands/README.md: no command frontmatter, skipped",
"commands/notes.md: no command frontmatter, skipped",
} {
if !strings.Contains(joined, want) {
t.Errorf("skip warning %q missing; got %v", want, skippedWarnings)
}
}
}