#!/usr/bin/env python3 """Validates frontmatter/metadata consistency across all subagent definitions. Checks every .md file in the 13 category folders for: - present YAML frontmatter (--- ... ---) - required fields: name, description, model - model is always "claude-sonnet-5" - no color: field - no empty tools: field - name matches the filename (without .md) - no broken leftover context-query text (e.g. "context needed: >-") - known merged/renamed files are not still referenced as active in SOURCES.md Exit code 0 on success, 1 with an error list otherwise. """ import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parent.parent CATEGORY_DIRS = [ "backend-apis", "sprachen", "frontend-ui", "datenbanken", "devops-infrastruktur", "security-compliance", "testing-qualitaet", "orchestrierung", "marketing-content", "data-ki-ml", "business-produkt", "mobile", "sonstiges", ] REQUIRED_FIELDS = ["name", "description", "model"] EXPECTED_MODEL = "claude-sonnet-5" # Files removed/merged during the deduplication pass. They must no longer # exist on disk, and must not be listed as an active row in SOURCES.md # (a "gemerged"/"umbenannt" mention referencing them is fine). DELETED_FILES = [ "architect-review.md", "vector-db-expert.md", "go-expert.md", "machine-learning-engineer.md", ] # old_name -> new_name for renamed agents RENAMED_FILES = { "devops-incident-responder.md": "incident-readiness-engineer.md", "review.md": "pipeline-pr-reviewer.md", } BROKEN_CONTEXT_RE = re.compile(r"context needed:\s*>-\s*$", re.IGNORECASE | re.MULTILINE) def parse_frontmatter(text: str): """Return (frontmatter_lines, body) or (None, text) if no frontmatter.""" if not text.startswith("---"): return None, text end = text.find("\n---", 3) if end == -1: return None, text frontmatter = text[3:end].strip("\n") body = text[end + 4 :] return frontmatter, body def parse_simple_yaml_fields(frontmatter: str): """Very small YAML subset parser sufficient for this repo's frontmatter. Returns dict of top-level scalar field name -> raw string value (quotes stripped where present). Multi-line block scalars are returned with their raw block value joined by spaces, mainly so we can still detect the field's presence and content. """ fields = {} lines = frontmatter.split("\n") i = 0 n = len(lines) while i < n: line = lines[i] m = re.match(r"^([A-Za-z_][A-Za-z0-9_]*):\s*(.*)$", line) if not m: i += 1 continue key, value = m.group(1), m.group(2) value = value.strip() if value in (">-", "|-", ">", "|"): # Block scalar: collect following indented lines block_lines = [] i += 1 while i < n and (lines[i].startswith(" ") or lines[i].strip() == ""): block_lines.append(lines[i].strip()) i += 1 fields[key] = " ".join(block_lines).strip() continue # Strip matching quotes if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": value = value[1:-1] fields[key] = value i += 1 return fields def validate_file(path: Path, errors: list): text = path.read_text(encoding="utf-8") frontmatter, _body = parse_frontmatter(text) if frontmatter is None: errors.append(f"{path}: missing YAML frontmatter (--- ... ---)") return fields = parse_simple_yaml_fields(frontmatter) for field in REQUIRED_FIELDS: if field not in fields or not fields[field].strip(): errors.append(f"{path}: missing required field '{field}'") if "model" in fields and fields["model"] != EXPECTED_MODEL: errors.append( f"{path}: model is '{fields['model']}', expected '{EXPECTED_MODEL}'" ) if "color" in fields: errors.append(f"{path}: has forbidden 'color' field") if "tools" in fields: tools_val = fields["tools"].strip() if tools_val in ("", "[]", '""', "''"): errors.append(f"{path}: has empty 'tools' field") expected_name = path.stem if "name" in fields and fields["name"].strip() != expected_name: errors.append( f"{path}: name '{fields['name']}' does not match filename '{expected_name}'" ) if BROKEN_CONTEXT_RE.search(frontmatter): errors.append(f"{path}: broken context-query leftover ('context needed: >-') in frontmatter") if BROKEN_CONTEXT_RE.search(text): errors.append(f"{path}: broken context-query leftover ('context needed: >-') in body") def validate_sources_md(errors: list): sources_path = REPO_ROOT / "SOURCES.md" if not sources_path.exists(): errors.append("SOURCES.md not found") return text = sources_path.read_text(encoding="utf-8") lines = text.split("\n") for deleted in DELETED_FILES: agent_name = deleted[:-3] for line in lines: if not line.startswith("|"): continue cols = [c.strip() for c in line.strip("|").split("|")] if not cols: continue if cols[0] == agent_name: errors.append( f"SOURCES.md: deleted file '{deleted}' still listed as an active agent row ('{agent_name}')" ) def main(): errors = [] for category in CATEGORY_DIRS: category_path = REPO_ROOT / category if not category_path.is_dir(): errors.append(f"category directory missing: {category}") continue for md_file in sorted(category_path.glob("*.md")): validate_file(md_file, errors) for deleted in DELETED_FILES: for category in CATEGORY_DIRS: if (REPO_ROOT / category / deleted).exists(): errors.append(f"{category}/{deleted}: should have been deleted (merged elsewhere)") for old_name in RENAMED_FILES: for category in CATEGORY_DIRS: if (REPO_ROOT / category / old_name).exists(): errors.append(f"{category}/{old_name}: should have been renamed to {RENAMED_FILES[old_name]}") validate_sources_md(errors) if errors: print(f"FAILED: {len(errors)} issue(s) found:\n") for err in errors: print(f" - {err}") return 1 print("OK: all subagent metadata checks passed.") return 0 if __name__ == "__main__": sys.exit(main())