Files
basicmachines-co-basic-memory/scripts/validate_skills.py
phernandez df3eb3208c fix(plugins): address v0.4 plugin code-review findings (PR #865)
Resolves the 8 self-review findings on PR #865:

session-start.sh:
- Wire recallTimeframe into a new "Recent sessions" query (--after_date) — it was
  parsed but unused after the Phase 4 rewrite. The brief now surfaces recent
  session checkpoints (the resume cursor), which is the one query the recency
  window legitimately applies to. (#1)
- Guard secondaryProjects/teamProjects JSON types — a string value was iterated
  character-by-character into bogus per-char queries. (#3)
- A configured-but-unreachable/misnamed primaryProject now emits a one-line
  "couldn't read" signal instead of a silent blank brief. (#7)

pre-compact.sh:
- Filter transcript turns by the `isMeta` and `toolUseResult` flags instead of a
  `text.startswith("<")` heuristic. This stops dropping legitimate user messages
  that start with "<" (#4) and stops capturing tool-result/meta frames as human
  turns (#8) — verified against a real transcript (25 human turns cleanly
  separated from 8 meta + 288 tool-result frames).
- Title now uses second precision so rapid same-minute compactions don't collide
  and silently drop/overwrite a checkpoint; removed the unused stamp/slug. (#2)
- Empty-checkpoint guard now requires at least one real user turn, not just any
  turn, so an assistant-only transcript can't produce a dangling-title note. (#5)

validate_skills.py:
- parse_frontmatter now skips indented lines, capturing only top-level keys, so a
  schema note's nested schema:/settings: children can't overwrite a top-level
  type/entity via last-write-wins. Documented its single-line-only limitation. (#6)

All verified end-to-end; `just package-check-claude-code` and
`package-check-skills` pass; ruff clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: phernandez <paul@basicmachines.co>
2026-05-31 12:31:08 -05:00

73 lines
2.5 KiB
Python

#!/usr/bin/env python3
"""Validate Basic Memory SKILL.md source directories."""
from __future__ import annotations
import argparse
from pathlib import Path
def parse_frontmatter(path: Path) -> dict[str, str]:
"""Extract top-level frontmatter keys from a Markdown file.
A deliberately minimal parser (no PyYAML — this runs under bare `python3` in
CI). It only captures **top-level** `key: value` lines. Indented lines are
skipped, so nested blocks (a schema note's `schema:`/`settings:` children) can't
overwrite a top-level key like `type` or `entity` via last-write-wins. It does
not interpret block scalars or multi-line values; callers rely on single-line
top-level fields (name, description, type, entity).
"""
lines = path.read_text().splitlines()
if not lines or lines[0] != "---":
raise SystemExit(f"{path}: missing YAML frontmatter")
frontmatter: dict[str, str] = {}
for line in lines[1:]:
if line == "---":
break
if line[:1] in (" ", "\t"): # nested key — not a top-level field
continue
if ":" not in line:
continue
key, value = line.split(":", 1)
frontmatter[key.strip()] = value.strip().strip('"')
else:
raise SystemExit(f"{path}: unclosed YAML frontmatter")
return frontmatter
def validate_skills(skills_root: Path) -> None:
if not skills_root.exists():
raise SystemExit(f"Skills directory not found: {skills_root}")
skill_dirs = sorted(path for path in skills_root.glob("memory-*") if path.is_dir())
if not skill_dirs:
raise SystemExit(f"No memory-* skill directories found in {skills_root}")
for skill_dir in skill_dirs:
skill_file = skill_dir / "SKILL.md"
if not skill_file.exists():
raise SystemExit(f"{skill_dir}: missing SKILL.md")
frontmatter = parse_frontmatter(skill_file)
name = frontmatter.get("name")
description = frontmatter.get("description")
if name != skill_dir.name:
raise SystemExit(f"{skill_file}: name {name!r} does not match directory")
if not description:
raise SystemExit(f"{skill_file}: missing description")
print(f"validated {len(skill_dirs)} skills in {skills_root}")
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("skills_root", nargs="?", default="skills")
args = parser.parse_args()
validate_skills((Path.cwd() / args.skills_root).resolve())
if __name__ == "__main__":
main()