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
+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