basic BMAD setup

This commit is contained in:
Ebbe Baß
2026-10-15 08:57:23 +02:00
parent 44029127dc
commit 5e747654b9
438 changed files with 41818 additions and 0 deletions
+116
View File
@@ -0,0 +1,116 @@
"""Shared strict TOML loading and structural merge support."""
from __future__ import annotations
import tomllib
from collections.abc import Iterable
from pathlib import Path
from typing import Any
class ConfigError(ValueError):
"""Raised when a present configuration layer cannot be used safely."""
_KEYED_MERGE_FIELDS = ("code", "id")
def load_toml(path: Path, *, required: bool = False) -> dict[str, Any]:
"""Load a TOML table, allowing absence only for optional layers."""
if not path.exists():
if required:
raise ConfigError(f"required TOML file not found: {path}")
return {}
if not path.is_file():
raise ConfigError(f"TOML layer is not a file: {path}")
try:
with path.open("rb") as stream:
parsed = tomllib.load(stream)
except tomllib.TOMLDecodeError as error:
raise ConfigError(f"failed to parse {path}: {error}") from error
except OSError as error:
raise ConfigError(f"failed to read {path}: {error}") from error
if not isinstance(parsed, dict):
raise ConfigError(f"TOML layer did not parse to a table: {path}")
return parsed
def _detect_keyed_merge_field(items: list[Any]) -> str | None:
if not items or not all(isinstance(item, dict) for item in items):
return None
for candidate in _KEYED_MERGE_FIELDS:
if all(candidate in item for item in items):
for item in items:
value = item[candidate]
if not isinstance(value, str):
raise ConfigError(
f"keyed array identifier `{candidate}` must be a string, got {type(value).__name__}"
)
if not value:
raise ConfigError(f"keyed array identifier `{candidate}` must not be empty")
return candidate
return None
def _merge_arrays(base: list[Any], override: list[Any]) -> list[Any]:
keyed_field = _detect_keyed_merge_field(base + override)
if keyed_field is None:
return list(base) + list(override)
result: list[Any] = []
index_by_key: dict[str, int] = {}
for item in base:
copied = dict(item)
index_by_key[copied[keyed_field]] = len(result)
result.append(copied)
for item in override:
copied = dict(item)
key = copied[keyed_field]
if key in index_by_key:
result[index_by_key[key]] = copied
else:
index_by_key[key] = len(result)
result.append(copied)
return result
def structural_merge(base: Any, override: Any) -> Any:
"""Merge tables recursively, keyed table arrays by identity, and append other arrays."""
if isinstance(base, dict) and isinstance(override, dict):
result = dict(base)
for key, value in override.items():
result[key] = structural_merge(result[key], value) if key in result else value
return result
if isinstance(base, list) and isinstance(override, list):
return _merge_arrays(base, override)
return override
def merge_layers(layers: Iterable[dict[str, Any]]) -> dict[str, Any]:
merged: dict[str, Any] = {}
for layer in layers:
merged = structural_merge(merged, layer)
return merged
def load_central_config(project_root: Path) -> dict[str, Any]:
bmad_dir = project_root / "_bmad"
return merge_layers(
(
load_toml(bmad_dir / "config.toml", required=True),
load_toml(bmad_dir / "custom" / "config.toml"),
load_toml(bmad_dir / "custom" / "config.user.toml"),
)
)
def load_customization(project_root: Path | None, skill_dir: Path) -> dict[str, Any]:
skill_name = skill_dir.name
custom_dir = project_root / "_bmad" / "custom" if project_root else None
return merge_layers(
(
load_toml(skill_dir / "customize.toml", required=True),
load_toml(custom_dir / f"{skill_name}.toml") if custom_dir else {},
load_toml(custom_dir / f"{skill_name}.user.toml") if custom_dir else {},
)
)
+462
View File
@@ -0,0 +1,462 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Report the knowledge documents the installed modules offer.
A folder whose `bmod.toml` has a `[bmod]` table is a module record, whatever
the folder is called. The record names the module's skills and holds each
knowledge document once. `help/help.md` in the record's folder covers every
skill of the module and needs no entry. A `[[bmod.knowledge]]` entry adds a further document
and says which of the module's skills it covers: `"*"` or no `skills` key means
all of them, a list means the named ones.
Every other `help/*.md` is a topic: detail that `help/help.md` points to and a
reader opens only when a question needs it. Topics are listed with their
file path and never with their text.
A `migration-<n>.toml` file in the record's folder is a migration the
module ships: the rules for moving a project from one major version of the
module to the next. `<n>` is a whole number that sets the order in which a
module's migrations are listed, checked and run. One is listed only when its
`[migration]` table names the record's `module` and has `from`, `to`,
`title`, `summary`, `detect`, `guide`, and a `checklist`; the listing carries
`from`, `to`, `title` and the file path, never the text. A `[migration]`
table in a file with any other name, and two files of one module with the
same number, are problems. `bmad migrate` reads the file.
A file this script cannot use becomes an entry in `problems`, never an
exception.
Usage:
uv run knowledge.py --root .claude/skills [--root ...] [--content]
"""
from __future__ import annotations
import argparse
import json
import re
import stat
import sys
import tomllib
from pathlib import Path, PurePosixPath
from typing import NamedTuple
sys.dont_write_bytecode = True
MANIFEST_NAME = "bmod.toml"
TOPICS_DIR = "help"
HELP_NAME = f"{TOPICS_DIR}/help.md"
ROSTER_NAME = "roster.toml"
RETIRED_NAME = "retired.toml"
MIGRATION_TABLE = "migration"
MIGRATION_FIELDS = ("module", "from", "to", "title", "summary", "detect", "guide")
MIGRATION_NAME = re.compile(r"migration-([0-9]+)\.toml")
READ_LIMIT = 1024 * 1024
class Module(NamedTuple):
code: str
folder: Path
table: dict[str, object]
skills: list[str]
class Scan(NamedTuple):
folders: dict[str, Path]
modules: list[Module]
skills: list[dict[str, object]]
problems: list[dict[str, object]]
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Report the knowledge documents the installed modules offer.")
parser.add_argument("--root", type=Path, action="append", required=True, help="a skills root to scan")
parser.add_argument("--content", action="store_true", help="include each document's text")
args = parser.parse_args(argv)
print(json.dumps(collect(args.root, include_content=args.content), ensure_ascii=False))
return 0
def collect(roots: list[Path], *, include_content: bool = False) -> dict[str, object]:
found = scan(roots)
problems = found.problems
documents: dict[tuple[str, str], dict[str, object]] = {}
for module in found.modules:
entries = module.table.get("knowledge", [])
if not isinstance(entries, list):
problems.append(
{"kind": "knowledge", "skill": module.folder.name, "problem": "[bmod] 'knowledge' is not a list"}
)
continue
if (module.folder / HELP_NAME).exists():
entries = [{"path": HELP_NAME}, *entries]
for entry in entries:
record_document(documents, problems, found.folders, module, entry, include_content=include_content)
topics = [
topic
for module in found.modules
for topic in module_topics(module, problems)
if (topic["module"], topic["path"]) not in documents
]
migrations = [migration for module in found.modules for migration in module_migrations(module, problems)]
return {
"roots": [str(root) for root in roots],
"skills": sorted(found.skills, key=lambda item: str(item["skill"])),
"documents": sorted(documents.values(), key=lambda item: (str(item["module"]), str(item["path"]))),
"topics": sorted(topics, key=lambda item: (str(item["module"]), str(item["path"]))),
"migrations": sorted(migrations, key=lambda item: (str(item["module"]), migration_number(str(item["path"])))),
"problems": problems,
}
def scan(roots: list[Path]) -> Scan:
"""Find every module record and every module skill in the roots."""
folders: dict[str, Path] = {}
modules: list[Module] = []
problems: list[dict[str, object]] = []
record_codes: dict[str, str] = {}
pending: list[tuple[Path, Path, dict[str, object], bool]] = []
for root in roots:
try:
found = sorted(path for path in root.iterdir() if path.is_dir())
except OSError as error:
problems.append({"kind": "root", "root": str(root), "problem": f"cannot read root {root}: {error}"})
continue
for folder in found:
# The first root wins a folder name outright: a project copy shadows a
# user copy even when the project copy carries no bmod.toml.
if folder.name in folders:
continue
folders[folder.name] = folder
manifest = folder / MANIFEST_NAME
try:
if not manifest.is_file():
continue
data = tomllib.loads(manifest.read_text(encoding="utf-8"))
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
problems.append(manifest_problem(folder, f"cannot use {manifest}: {error}"))
continue
if "bmod" not in data and "skill" not in data:
problems.append(manifest_problem(folder, f"{manifest} has neither a [bmod] nor a [skill] table"))
continue
skill = data.get("skill")
if "skill" in data and not isinstance(skill, dict):
problems.append(manifest_problem(folder, f"{manifest}: 'skill' is not a table"))
skill = None
if "bmod" in data:
module = read_record(folder, data["bmod"], skill is not None, problems)
if module is not None:
record_codes[folder.name] = module.code
first = next((other for other in modules if other.code.casefold() == module.code.casefold()), None)
if first is None:
modules.append(module)
else:
problems.append(
{
"kind": "module",
"skill": folder.name,
"problem": f"{folder.name}: module {module.code!r} is already recorded by "
f"{first.folder.name}; {first.folder.name} is used",
}
)
if skill is not None:
pending.append((root, folder, skill, "bmod" in data))
skills = [
resolve_skill(root, folder, table, own_record, record_codes) for root, folder, table, own_record in pending
]
problems.extend(absent_records(skills, folders))
for entry in skills:
entry.pop("source")
return Scan(folders, modules, skills, problems)
def manifest_problem(folder: Path, problem: str) -> dict[str, object]:
return {"kind": "manifest", "skill": folder.name, "manifest": str(folder / MANIFEST_NAME), "problem": problem}
def read_record(folder: Path, table: object, has_skill: bool, problems: list[dict[str, object]]) -> Module | None:
manifest = folder / MANIFEST_NAME
if not isinstance(table, dict):
problems.append(manifest_problem(folder, f"{manifest}: 'bmod' is not a table"))
return None
code = table.get("code")
if not isinstance(code, str) or not code:
problems.append(manifest_problem(folder, f"{manifest}: [bmod] has no usable 'code'"))
return None
listed = table.get("skills")
if listed is None:
# A record that is also a skill, with no list, is its own one member.
members = [folder.name] if has_skill else []
elif isinstance(listed, list) and all(isinstance(name, str) and name for name in listed):
members = list(dict.fromkeys(listed))
else:
problems.append(manifest_problem(folder, f"{manifest}: [bmod] 'skills' is not a list of skill names"))
members = []
return Module(code, folder, table, members)
def resolve_skill(
root: Path, folder: Path, table: dict[str, object], own_record: bool, record_codes: dict[str, str]
) -> dict[str, object]:
bmod = folder.name if own_record else table.get("bmod")
if not isinstance(bmod, str) or not bmod:
bmod = None
return {
"skill": folder.name,
"module": record_codes.get(bmod) if bmod else None,
"bmod": bmod,
"root": str(root),
"source": table.get("source"),
}
def absent_records(skills: list[dict[str, object]], folders: dict[str, Path]) -> list[dict[str, object]]:
"""One problem per module record that skills name and no root holds."""
problems: list[dict[str, object]] = []
by_bmod: dict[str, list[dict[str, object]]] = {}
for entry in skills:
if entry["module"] is not None:
continue
if entry["bmod"] is None:
problems.append(
{
"kind": "manifest",
"skill": entry["skill"],
"problem": f"{entry['skill']}: [skill] does not name its module record under 'bmod'",
}
)
continue
by_bmod.setdefault(str(entry["bmod"]), []).append(entry)
for bmod, entries in sorted(by_bmod.items()):
names = sorted(str(entry["skill"]) for entry in entries)
state = "has no usable module record" if bmod in folders else "is not installed"
problem: dict[str, object] = {
"kind": "module",
"bmod": bmod,
"skills": names,
"problem": f"module record {bmod} {state}; it is named by {', '.join(names)}",
}
command = None if bmod in folders else install_command(entries[0]["source"], bmod)
if command:
problem["install"] = command
problem["problem"] = f"{problem['problem']}; install it with `{command}`"
problems.append(problem)
return problems
def install_command(source: object, skill: str) -> str | None:
if not isinstance(source, str) or not source.startswith("github:"):
return None
parts = source.removeprefix("github:").split("/")
if len(parts) < 2 or not all(parts[:2]):
return None
return f"npx skills add {parts[0]}/{parts[1]} --skill {skill}"
def record_document(
documents: dict[tuple[str, str], dict[str, object]],
problems: list[dict[str, object]],
folders: dict[str, Path],
module: Module,
entry: object,
*,
include_content: bool,
) -> None:
folder = module.folder
name = entry.get("path") if isinstance(entry, dict) else None
if not isinstance(name, str):
problems.append(
{"kind": "knowledge", "skill": folder.name, "problem": f"knowledge entry {entry!r} has no path"}
)
return
relative = safe_skill_relative(name)
if relative is None:
problems.append({"kind": "knowledge", "skill": folder.name, "problem": f"knowledge names unsafe path {name!r}"})
return
covered = entry.get("skills", "*")
if covered == "*":
skills = list(module.skills)
elif isinstance(covered, list) and all(isinstance(skill, str) and skill for skill in covered):
skills = list(dict.fromkeys(covered))
else:
problems.append(
{
"kind": "knowledge",
"skill": folder.name,
"problem": f"knowledge entry {name!r}: 'skills' is neither \"*\" nor a list of skill names",
}
)
return
key = (module.code, relative.as_posix())
if key in documents:
problems.append({"kind": "knowledge", "skill": folder.name, "problem": f"knowledge names {name!r} twice"})
return
path = folder.joinpath(*relative.parts)
try:
raw = read_document(path, folder)
except (OSError, ValueError) as error:
problems.append(
{"kind": "document", "skill": folder.name, "document": str(path), "problem": f"{path}: {error}"}
)
return
try:
text = raw.decode("utf-8")
except UnicodeError as error:
problems.append(
{
"kind": "document",
"skill": folder.name,
"document": str(path),
"problem": f"{path}: not valid UTF-8, so it is not a knowledge document: {error}",
}
)
return
document: dict[str, object] = {
"module": module.code,
"path": relative.as_posix(),
"skills": skills,
"installed_skills": [skill for skill in skills if skill in folders],
"reported_from": folder.name,
}
if include_content:
document["content"] = text
documents[key] = document
def module_topics(module: Module, problems: list[dict[str, object]]) -> list[dict[str, object]]:
folder = module.folder
try:
found = sorted(path for path in (folder / TOPICS_DIR).glob("*.md") if path != folder / HELP_NAME)
except OSError:
return []
topics: list[dict[str, object]] = []
for path in found:
try:
read_document(path, folder).decode("utf-8")
except (OSError, ValueError) as error:
problems.append(
{"kind": "document", "skill": folder.name, "document": str(path), "problem": f"{path}: {error}"}
)
continue
topics.append(
{"module": module.code, "topic": path.stem, "path": f"{TOPICS_DIR}/{path.name}", "file": str(path)}
)
return topics
def module_migrations(module: Module, problems: list[dict[str, object]]) -> list[dict[str, object]]:
"""The migrations a record ships: its `migration-<n>.toml` files, in number order."""
folder = module.folder
try:
found = sorted(
path for path in folder.glob("*.toml") if path.name not in (MANIFEST_NAME, ROSTER_NAME, RETIRED_NAME)
)
except OSError:
return []
migrations: list[dict[str, object]] = []
for path in found:
try:
data = tomllib.loads(read_document(path, folder).decode("utf-8"))
except (OSError, ValueError) as error:
problems.append(migration_problem(folder, path, str(error)))
continue
if MIGRATION_TABLE not in data:
continue
table = data[MIGRATION_TABLE]
if not isinstance(table, dict):
problems.append(migration_problem(folder, path, "'migration' is not a table"))
continue
fields = {name: table.get(name) for name in MIGRATION_FIELDS}
missing = [name for name, value in fields.items() if not isinstance(value, str) or not value.strip()]
checklist = table.get("checklist")
if (
not isinstance(checklist, list)
or not checklist
or not all(isinstance(item, str) and item.strip() for item in checklist)
):
missing.append("checklist")
if missing:
problems.append(migration_problem(folder, path, f"[migration] needs non-empty {', '.join(missing)}"))
continue
if fields["module"] != module.code:
problems.append(
migration_problem(
folder, path, f"[migration] module {fields['module']!r} is not this record's {module.code!r}"
)
)
continue
if migration_number(path.name) is None:
problems.append(migration_problem(folder, path, "a migration file must be named migration-<n>.toml"))
continue
listed = {name: fields[name] for name in ("from", "to", "title")}
migrations.append({"module": module.code, "path": path.name, "file": str(path), **listed})
numbers = [migration_number(str(item["path"])) for item in migrations]
duplicated = {number for number in numbers if numbers.count(number) > 1}
for item in migrations:
if migration_number(str(item["path"])) in duplicated:
problems.append(
migration_problem(
folder, Path(str(item["file"])), "another migration of this module has the same number"
)
)
kept = [item for item in migrations if migration_number(str(item["path"])) not in duplicated]
return sorted(kept, key=lambda item: migration_number(str(item["path"])))
def migration_number(name: str) -> int | None:
match = MIGRATION_NAME.fullmatch(name)
return int(match.group(1)) if match else None
def migration_problem(folder: Path, path: Path, problem: str) -> dict[str, object]:
return {"kind": "migration", "skill": folder.name, "document": str(path), "problem": f"{path}: {problem}"}
def read_document(path: Path, folder: Path) -> bytes:
"""Read a knowledge document, refusing anything that is not a plain file inside the folder."""
resolved = path.resolve()
if not resolved.is_relative_to(folder.resolve()):
raise ValueError("resolves outside the skill folder")
status = resolved.stat()
if not stat.S_ISREG(status.st_mode):
raise ValueError("is not a regular file")
with resolved.open("rb") as handle:
raw = handle.read(READ_LIMIT + 1)
if len(raw) > READ_LIMIT:
raise ValueError(f"is larger than {READ_LIMIT} bytes")
return raw
def safe_skill_relative(entry: str) -> PurePosixPath | None:
"""A bmod.toml path that cannot escape the skill folder, or None if it can.
Mirrors safe_skill_relative in setup.py. A URL parses as an ordinary
relative path and a Windows drive prefix makes a later join discard the
skill folder, so both are refused by name. pathlib drops "." components
itself, so only ".." and an empty final component need checking.
"""
if not entry or "://" in entry or "\\" in entry or ":" in entry:
return None
relative = PurePosixPath(entry)
if relative.is_absolute() or ".." in relative.parts or not relative.name:
return None
return relative
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.exit(main())
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""memlog — an append-only memory log: LLM-optimal working memory for a skill.
A memlog is the dense, chronological record of everything that mattered in a piece of
work — every item the user generated or accepted — kept minimal like human memory: only
what's important, never bloated. It persists ACROSS sessions, so a fresh session can
load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a
deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary
by how it calls `append` — the tool stays neutral.
It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded
at the END in the order it happened. The chronology itself is the structure — an event
like "started technique X" is just another entry, same as an idea or an insight.
Three invariants make it trustworthy:
1. Append-only, chronological. Entries land at the end, in the order they happen.
Nothing is ever inserted backward, reordered, edited, or removed. There is no
edit or delete subcommand by design; history is never rewritten.
2. Write-only / blind. Every command is a context-free write and echoes the new state
as one line of JSON, so the caller never re-reads the file mid-session. `append` adds
its line with one OS append write, so concurrent appenders never drop each other.
The one time the file is read is on resume — and the caller reads it itself, not
via this script.
3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done,
blocked, or paused is itself a fact that happened, so it is recorded as an entry
(e.g. `append --type event --text "session complete"`), never as frontmatter the
log would have to mutate. The chronology stays the single source of truth, and a
resume learns the state by reading the last entries — the same way it learns
everything else.
Atomicity: `init` and `set` write a temp file, flush and fsync it, then atomically rename
it over the target. `append` never rewrites the file: it adds the entry at the end of file
in a single append write (O_APPEND on POSIX, a FILE_APPEND_DATA handle on Windows), which the
OS places after whatever other processes appended, so parallel appends all land.
The file shape (.memlog.md):
---
topic: Onboarding flow for a budgeting app
goal: lift week-1 retention
updated: 2026-06-07T14:22
---
- (note) user picked techniques: SCAMPER, then Six Thinking Hats
- (technique) started SCAMPER
- (idea) skip the signup wall: let people try with sample data first
- (idea) auto-import one bank account so the first screen shows real numbers
- (question) is open-banking consent too heavy for step one?
- (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
- (direction) optimize for the anxious first-timer, not the power user
- (decision) lead with one pre-categorized account; defer multi-account import
- (event) session complete
Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
decision, direction, assumption, gap, note, event, …) — and an optional `--by` naming
who it came from (e.g. `user`, `coach`), for sessions where authorship matters. Both
render into one short inline tag: `(idea)`, `(idea by user)`, `(by coach)`. Omit them
for a plain note. The host skill names the vocabulary; the script does not enforce one.
Commands:
init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists)
append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end
set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field
Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md.
`--path` points straight at the memlog file instead, for callers that already hold the path.
"""
from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+
import argparse
import json
import os
import sys
from datetime import datetime
from pathlib import Path
MEMLOG = ".memlog.md"
def now() -> str:
return datetime.now().strftime("%Y-%m-%dT%H:%M")
def resolve(args) -> Path:
"""The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path."""
return Path(args.path) if args.path else Path(args.workspace) / MEMLOG
def split(text: str) -> tuple[dict, str]:
"""Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
The closing fence is the first line that is *exactly* `---`, so a `---` inside a
field value (topic/goal are free user text) never truncates the frontmatter.
"""
lines = text.splitlines()
if not lines or lines[0] != "---":
raise ValueError(".memlog.md has no frontmatter")
end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
if end is None:
raise ValueError(".memlog.md frontmatter is not terminated")
meta: dict[str, str] = {}
for line in lines[1:end]:
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip()
return meta, "\n".join(lines[end + 1 :]).lstrip("\n")
def render(meta: dict, body: str) -> str:
# Neutralize newlines in values so a multi-line field can't break the fence on re-read.
fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"
def touch(meta: dict) -> None:
"""Stamp `updated` and keep it last so the field order stays predictable."""
meta.pop("updated", None)
meta["updated"] = now()
def write_atomic(path: Path, text: str) -> None:
"""Temp + flush + fsync + atomic rename, so a crash never half-writes an entry."""
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as f:
f.write(text)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
def append_line(path: Path, text: str) -> None:
"""Add text at end of file in one OS append write, then sync it to disk. Never creates the file."""
data = text.replace("\n", os.linesep).encode("utf-8") # match text-mode line endings
if sys.platform != "win32":
fd = os.open(path, os.O_WRONLY | os.O_APPEND)
try:
if os.write(fd, data) != len(data):
raise OSError(f"short write appending to {path}")
os.fsync(fd)
finally:
os.close(fd)
return
# The CRT's append mode seeks then writes in two steps; a handle with only
# FILE_APPEND_DATA access makes Windows itself place every write at end of file.
import ctypes
from ctypes import wintypes
k32 = ctypes.WinDLL("kernel32", use_last_error=True)
k32.CreateFileW.argtypes = [
wintypes.LPCWSTR,
wintypes.DWORD,
wintypes.DWORD,
ctypes.c_void_p,
wintypes.DWORD,
wintypes.DWORD,
wintypes.HANDLE,
]
k32.CreateFileW.restype = wintypes.HANDLE
k32.WriteFile.argtypes = [
wintypes.HANDLE,
ctypes.c_char_p,
wintypes.DWORD,
ctypes.POINTER(wintypes.DWORD),
ctypes.c_void_p,
]
k32.WriteFile.restype = wintypes.BOOL
k32.FlushFileBuffers.argtypes = [wintypes.HANDLE]
k32.FlushFileBuffers.restype = wintypes.BOOL
k32.CloseHandle.argtypes = [wintypes.HANDLE]
file_append_data, share_all, open_existing, normal = 0x4, 0x7, 3, 0x80
handle = k32.CreateFileW(str(path), file_append_data, share_all, None, open_existing, normal, None)
if handle is None or handle == wintypes.HANDLE(-1).value:
raise ctypes.WinError(ctypes.get_last_error())
try:
written = wintypes.DWORD()
if not k32.WriteFile(handle, data, len(data), ctypes.byref(written), None):
raise ctypes.WinError(ctypes.get_last_error())
if written.value != len(data):
raise OSError(f"short write appending to {path}")
if not k32.FlushFileBuffers(handle):
raise ctypes.WinError(ctypes.get_last_error())
finally:
k32.CloseHandle(handle)
def entry_count(body: str) -> int:
return sum(1 for ln in body.splitlines() if ln.startswith("- "))
def ack(path: Path, body: str) -> None:
"""Echo new state so the caller never re-reads the file to know where it stands."""
print(
json.dumps(
{
"ok": True,
"memlog": str(path),
"entries": entry_count(body),
}
)
)
def cmd_init(args) -> int:
path = resolve(args)
if path.exists():
print(f"error: {path} already exists; use append/set to update it", file=sys.stderr)
return 2
path.parent.mkdir(parents=True, exist_ok=True)
meta: dict[str, str] = {}
for pair in args.field or []:
if "=" not in pair:
print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr)
return 2
k, v = pair.split("=", 1)
meta[k.strip()] = v.strip()
touch(meta)
write_atomic(path, render(meta, ""))
ack(path, "")
return 0
def cmd_append(args) -> int:
path = resolve(args)
raw = path.read_text(encoding="utf-8")
split(raw) # a missing or malformed log fails here, before anything is written
text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat
label = args.type or ""
if args.by:
label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)"
tag = f"({label}) " if label else ""
entry = f"- {tag}{text}"
append_line(path, ("" if raw.endswith("\n") else "\n") + entry + "\n")
ack(path, split(path.read_text(encoding="utf-8"))[1])
return 0
def cmd_set(args) -> int:
path = resolve(args)
meta, body = split(path.read_text(encoding="utf-8"))
meta[args.key] = args.value
touch(meta)
write_atomic(path, render(meta, body))
ack(path, body)
return 0
def add_target(sp) -> None:
"""Every command addresses the memlog the same way: a run folder or an explicit path."""
g = sp.add_mutually_exclusive_group(required=True)
g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md")
g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)")
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
sub = p.add_subparsers(dest="cmd", required=True)
pi = sub.add_parser("init", help="create the memlog")
add_target(pi)
pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)")
pi.set_defaults(func=cmd_init)
pa = sub.add_parser("append", help="append one entry at the end")
add_target(pa)
pa.add_argument("--text", required=True)
pa.add_argument("--type", help="entry kind, rendered as an inline tag")
pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag")
pa.set_defaults(func=cmd_append)
pset = sub.add_parser("set", help="set a descriptive frontmatter field")
add_target(pset)
pset.add_argument("--key", required=True)
pset.add_argument("--value", required=True)
pset.set_defaults(func=cmd_set)
args = p.parse_args(argv)
return args.func(args)
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.exit(main())
+319
View File
@@ -0,0 +1,319 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Report or remove the v6 traces in `_bmad/` that v7 does not read.
With no flag it lists them: the classic installer's files, the v6 install answers
v7 has no key for, and customizations of skills that are not installed. With
`--clean` it copies `_bmad/` to `.v6-v7-migration-backup-<datetime>/`, which the
method migration reuses, then removes them.
Usage:
uv run migrate_v6.py --project-root P --skill <the bmad skill> [--root R ...] [--clean]
"""
from __future__ import annotations
import argparse
import datetime
import importlib.util
import re
import shutil
import sys
import tomllib
from pathlib import Path, PurePosixPath
sys.dont_write_bytecode = True
def _load_setup():
spec = importlib.util.spec_from_file_location("bmad_setup", Path(__file__).with_name("setup.py"))
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
_setup = _load_setup()
Installation = _setup.Installation
Retirement = _setup.Retirement
TABLE_HEADER = _setup.TABLE_HEADER
TEAM_CONFIG = _setup.TEAM_CONFIG
USER_CONFIG = _setup.USER_CONFIG
_MISSING = _setup._MISSING
discover_installation = _setup.discover_installation
header_path = _setup.header_path
lookup = _setup.lookup
present = _setup.present
reject_unusable_bmad = _setup.reject_unusable_bmad
render_toml = _setup.render_toml
retired_skills = _setup.retired_skills
retirement_report = _setup.retirement_report
write_text = _setup.write_text
# Traces the classic installer leaves under _bmad.
LEGACY_LEFTOVERS = (
"_config/manifest.yaml",
"_config/files-manifest.csv",
"_config/skill-manifest.csv",
"_config/bmad-help.csv",
"config.user.toml",
"core/config.yaml",
"core/module-help.csv",
"core/v6-shims",
"bmm/config.yaml",
"bmm/module-help.csv",
"bmm/v6-shims",
)
# The v6.12 install answers v7 has no key for. Only these are removed, so a key added later is never touched.
V6_KEYS = (
("core", "user_name"),
("core", "communication_language"),
("core", "document_output_language"),
("modules", "bmm", "user_skill_level"),
("modules", "bmm", "planning_artifacts"),
("modules", "bmm", "implementation_artifacts"),
("modules", "bmm", "project_knowledge"),
)
CENTRAL_CONFIGS = (TEAM_CONFIG, "_bmad/custom/config.toml", USER_CONFIG)
BACKUP_PREFIX = ".v6-v7-migration-backup-"
def legacy_leftovers(project_root: Path) -> list[str]:
bmad = project_root / "_bmad"
return [
relative
for relative in LEGACY_LEFTOVERS
if (path := bmad.joinpath(*PurePosixPath(relative).parts)).exists() and inside_bmad(path, project_root)
]
def inside_bmad(path: Path, project_root: Path) -> bool:
"""A link along the way could point a cleanup outside the project; such a path is not cleaned."""
return path.resolve().is_relative_to((project_root / "_bmad").resolve())
def stale_config_keys(project_root: Path) -> list[tuple[str, tuple[str, ...]]]:
"""The V6_KEYS set in the central config files."""
stale: list[tuple[str, tuple[str, ...]]] = []
for relative in CENTRAL_CONFIGS:
path = project_root / relative
if path.is_symlink() or not path.is_file() or not inside_bmad(path, project_root):
continue
try:
data = tomllib.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, tomllib.TOMLDecodeError):
continue
stale += [(relative, key) for key in V6_KEYS if lookup(data, key) is not _MISSING]
return stale
def unused_customizations(project_root: Path, installation: Installation, retirement: Retirement) -> list[str]:
"""`_bmad/custom/` files of skills that are not installed. Files setup is about to rename are not unused."""
custom = project_root / "_bmad" / "custom"
if custom.is_symlink() or not custom.is_dir():
return []
moving = {old for old, _new in (*retirement.renames, *retirement.unmoved)}
unused: list[str] = []
for path in sorted(custom.iterdir(), key=lambda entry: entry.name):
name = path.name
stem = name.removesuffix(".toml").removesuffix(".user")
if not name.startswith("bmad-") or not name.endswith(".toml") or name in moving:
continue
if stem not in installation.folders:
unused.append(name)
return unused
def traces_json(project_root: Path, installation: Installation, retirement: Retirement) -> dict[str, object]:
return {
"legacy_leftovers": legacy_leftovers(project_root),
"stale_config_keys": [
{"file": relative, "key": ".".join(path)} for relative, path in stale_config_keys(project_root)
],
"unused_customizations": [
f"_bmad/custom/{name}" for name in unused_customizations(project_root, installation, retirement)
],
}
def clean(project_root: Path, skill_root: Path, *, roots: tuple[Path, ...] = ()) -> dict[str, object]:
"""Back up `_bmad/`, then remove the v6 traces v7 does not read: the classic installer's files, v6
config keys, and customizations of skills that are not installed."""
reject_unusable_bmad(project_root)
bmad = project_root / "_bmad"
installation = discover_installation(skill_root, roots)
if installation.missing_records:
# Without every module record, a renamed skill's customizations would look unused.
raise Exception("a module record is missing; run setup to repair the installation before cleaning up")
retirement = retirement_report(
project_root, skill_root, installation, retired_skills(installation.modules, installation.modules)
)
leftovers = legacy_leftovers(project_root)
stale = stale_config_keys(project_root)
unused = unused_customizations(project_root, installation, retirement)
edits = {
relative: text_without_keys(
(project_root / relative).read_text(encoding="utf-8"), [key for file, key in stale if file == relative]
)
for relative in dict.fromkeys(file for file, _path in stale)
}
report: dict[str, object] = {
"mode": "clean",
"backup": None,
"removed_files": [],
"removed_keys": [],
"removed_customizations": [],
}
if not (leftovers or stale or unused):
return report
backup = backup_bmad(project_root)
report["backup"] = backup.relative_to(project_root).as_posix()
for relative in leftovers:
path = bmad.joinpath(*PurePosixPath(relative).parts)
if path.is_dir() and not path.is_symlink():
shutil.rmtree(path)
else:
path.unlink()
prune_empty_parents(path.parent, bmad)
for relative, text in edits.items():
write_text(project_root / relative, text)
for name in unused:
(bmad / "custom" / name).unlink()
report["removed_files"] = [f"_bmad/{relative}" for relative in leftovers]
report["removed_keys"] = [{"file": relative, "key": ".".join(path)} for relative, path in stale]
report["removed_customizations"] = [f"_bmad/custom/{name}" for name in unused]
return report
def backup_bmad(project_root: Path) -> Path:
"""Copy `_bmad/` into a new `.v6-v7-migration-backup-<datetime>/`, which the method migration reuses.
Its `.gitignore` keeps the copy, personal settings included, out of every commit."""
now = datetime.datetime.now()
backup = project_root / f"{BACKUP_PREFIX}{now:%Y%m%d-%H%M}"
if present(backup):
backup = project_root / f"{BACKUP_PREFIX}{now:%Y%m%d-%H%M%S}"
shutil.copytree(project_root / "_bmad", backup / "_bmad", symlinks=True)
write_text(backup / ".gitignore", "*")
return backup
def prune_empty_parents(folder: Path, stop: Path) -> None:
while folder != stop and folder.is_dir() and not folder.is_symlink() and not any(folder.iterdir()):
folder.rmdir()
folder = folder.parent
def text_without_keys(text: str, paths: list[tuple[str, ...]]) -> str:
"""The file's text with these keys removed, so comments and layout survive.
A key written on one line in its table's section is cut by text, and a table left
empty loses its header. When the result does not parse to the expected values,
the whole file is rendered instead.
"""
expected = tomllib.loads(text)
for path in paths:
drop_path(expected, path)
lines = text.split("\n")
for path in paths:
lines = lines_without_key(lines, path[:-1], path[-1])
headers = [index for index, line in enumerate(lines) if TABLE_HEADER.match(line)]
emptied = {
index
for position, index in enumerate(headers)
if (name := header_path(lines[index])) is not None
and lookup(expected, name) is _MISSING
and not any(
line.strip() and not line.lstrip().startswith("#")
for line in lines[index + 1 : headers[position + 1] if position + 1 < len(headers) else len(lines)]
)
}
candidate = "\n".join(line for index, line in enumerate(lines) if index not in emptied)
try:
if tomllib.loads(candidate) == expected:
return candidate
except tomllib.TOMLDecodeError:
pass
return render_toml(expected)
def drop_path(data: dict, path: tuple[str, ...]) -> None:
"""Delete the key at path, then any table the deletion left empty."""
parents = [data]
for part in path[:-1]:
child = parents[-1].get(part)
if not isinstance(child, dict):
return
parents.append(child)
parents[-1].pop(path[-1], None)
for depth in range(len(path) - 1, 0, -1):
if parents[depth]:
break
parents[depth - 1].pop(path[depth - 1], None)
def lines_without_key(lines: list[str], table: tuple[str, ...], leaf: str) -> list[str]:
start, end = 0, len(lines)
if table:
headers = [(index, header_path(line)) for index, line in enumerate(lines) if TABLE_HEADER.match(line)]
for position, (index, name) in enumerate(headers):
if name == table:
start = index + 1
end = headers[position + 1][0] if position + 1 < len(headers) else len(lines)
break
else:
return lines
else:
end = next((index for index, line in enumerate(lines) if TABLE_HEADER.match(line)), len(lines))
key = re.compile(rf"\s*(?:{re.escape(leaf)}|\"{re.escape(leaf)}\"|'{re.escape(leaf)}')\s*=")
for index in range(start, end):
if not key.match(lines[index]):
continue
# A multi-line value ends at the first line after which the file parses again.
for stop in range(index + 1, end + 1):
remaining = [*lines[:index], *lines[stop:]]
try:
tomllib.loads("\n".join(remaining))
except tomllib.TOMLDecodeError:
continue
return remaining
return lines
return lines
def report(project_root: Path, skill_root: Path, *, roots: tuple[Path, ...] = ()) -> dict[str, object]:
"""The traces `--clean` would remove. Reads only."""
reject_unusable_bmad(project_root)
installation = discover_installation(skill_root, roots)
retirement = retirement_report(
project_root, skill_root, installation, retired_skills(installation.modules, installation.modules)
)
return {"mode": "report", **traces_json(project_root, installation, retirement)}
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Report or remove the v6 traces in _bmad that v7 does not read.")
parser.add_argument("--project-root", type=Path, required=True)
parser.add_argument("--skill", type=Path, required=True)
parser.add_argument("--root", type=Path, action="append", default=[], help="an active skills folder, repeated")
parser.add_argument("--clean", action="store_true", help="back up _bmad, then remove the traces")
args = parser.parse_args(argv)
project_root = args.project_root.resolve()
skill_root = args.skill.resolve()
roots = tuple(root.resolve() for root in args.root)
run = clean if args.clean else report
_setup.print_json(run(project_root, skill_root, roots=roots))
return 0
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
try:
raise SystemExit(main())
except Exception as error:
sys.stderr.write(f"error: {error}\n")
raise SystemExit(1) from None
+689
View File
@@ -0,0 +1,689 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["jinja2>=3.1"]
# ///
"""Render a skill's Markdown sources into an immutable project snapshot."""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import re
import shutil
import sys
import tomllib
from collections.abc import Callable
from datetime import date, time
from pathlib import Path
from typing import Any
import jinja2
# Installed scripts are consumer files, not a location for interpreter caches.
sys.dont_write_bytecode = True
from config_utils import ( # noqa: E402
ConfigError,
load_central_config,
load_customization,
load_toml,
structural_merge,
)
class RenderError(ValueError):
"""Raised when rendering cannot safely publish a snapshot."""
class _ArgumentParser(argparse.ArgumentParser):
def error(self, message: str) -> None:
raise RenderError(message)
_PARAMETER = r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*"
def _hash_bytes(content: bytes) -> str:
return hashlib.sha256(content).hexdigest()
def _canonical_json(value: Any) -> bytes:
return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":"), default=_json_scalar).encode(
"utf-8"
)
def _json_scalar(value: Any) -> str:
if isinstance(value, (date, time)):
return value.isoformat()
raise TypeError(f"unsupported JSON value: {type(value).__name__}")
def _toml_literal(text: str, label: str) -> Any:
try:
parsed = tomllib.loads(f"value = {text}")
except tomllib.TOMLDecodeError as error:
raise RenderError(f"invalid TOML value for {label}: {error}") from error
if set(parsed) != {"value"}:
raise RenderError(f"{label} must contain a single TOML value")
return parsed["value"]
def _invocation_customization(
defaults: dict[str, Any], overrides: Path | None, assignments: list[str]
) -> tuple[dict[str, Any], dict[str, Any]]:
file_layer = load_toml(overrides, required=True) if overrides is not None else {}
command_layer: dict[str, Any] = {}
assigned: list[str] = []
for assignment in assignments:
path, separator, raw = assignment.partition("=")
if not separator or re.fullmatch(_PARAMETER, path) is None:
raise RenderError(f"invalid --set assignment {assignment!r}; expected bare dotted key=value")
# A repeated or overlapping path is a caller mistake, not a precedence rule.
for earlier in assigned:
if path == earlier or path.startswith(f"{earlier}.") or earlier.startswith(f"{path}."):
raise RenderError(f"--set `{path}` conflicts with earlier --set `{earlier}`")
assigned.append(path)
default = _lookup(defaults, path, "customization parameter")
value = (
raw if isinstance(default, str) and not raw.lstrip().startswith(('"', "'")) else _toml_literal(raw, path)
)
target = command_layer
parts = path.split(".")
for part in parts[:-1]:
target = target.setdefault(part, {})
target[parts[-1]] = value
return file_layer, command_layer
def _leaf_paths(table: dict[str, Any], prefix: str = "") -> set[str]:
leaves: set[str] = set()
for key, value in table.items():
path = f"{prefix}{key}"
if isinstance(value, dict):
leaves |= _leaf_paths(value, f"{path}.")
else:
leaves.add(path)
return leaves
def _declares(defaults: dict[str, Any], path: str) -> bool:
node: Any = defaults
for part in path.split("."):
if not isinstance(node, dict) or part not in node:
return False
node = node[part]
return True
def _check_persistent_layers(project_root: Path, skill_dir: Path, defaults: dict[str, Any] | None) -> None:
"""A persistent override may only set keys the skill declares; a stale or misspelled key halts."""
custom_dir = project_root / "_bmad" / "custom"
for layer in (custom_dir / f"{skill_dir.name}.toml", custom_dir / f"{skill_dir.name}.user.toml"):
undeclared = sorted(path for path in _leaf_paths(load_toml(layer)) if not _declares(defaults or {}, path))
if undeclared:
raise RenderError(f"{layer} sets keys {skill_dir.name} does not declare: {', '.join(undeclared)}")
def _lookup(data: dict[str, Any], dotted_path: str, label: str) -> Any:
current: Any = data
for part in dotted_path.split("."):
if not isinstance(current, dict) or part not in current:
raise RenderError(f"missing {label} `{dotted_path}`")
current = current[part]
return current
def _require_string(value: Any, label: str, *, allow_empty: bool = False) -> str:
if not isinstance(value, str):
raise RenderError(f"{label} must be a string, got {type(value).__name__}")
if not allow_empty and not value.strip():
raise RenderError(f"{label} must not be empty")
return value
def _require_string_list(value: Any, label: str) -> list[str]:
if not isinstance(value, list):
raise RenderError(f"{label} must be a list, got {type(value).__name__}")
result = []
for index, item in enumerate(value):
result.append(_require_string(item, f"{label}[{index}]"))
return result
def _require_review_layers(value: Any, label: str) -> list[dict[str, str]]:
if not isinstance(value, list):
raise RenderError(f"{label} must be a list of tables")
result: list[dict[str, str]] = []
seen: set[str] = set()
for index, item in enumerate(value):
item_label = f"{label}[{index}]"
if not isinstance(item, dict):
raise RenderError(f"{item_label} must be a table")
identifier = _require_string(item.get("id"), f"{item_label}.id")
if identifier in seen:
raise RenderError(f"duplicate review layer id `{identifier}`")
seen.add(identifier)
layer = {
"id": identifier,
"name": _require_string(item.get("name", identifier), f"{item_label}.name"),
"instruction": _require_string(item.get("instruction"), f"{item_label}.instruction", allow_empty=True),
}
if "when" in item:
layer["when"] = _require_string(item["when"], f"{item_label}.when")
result.append(layer)
return result
def _load_sources(skill_dir: Path) -> dict[str, str]:
sources: dict[str, str] = {}
for candidate in sorted(skill_dir.rglob("*.md")):
if candidate.name == "SKILL.md":
continue
name = candidate.relative_to(skill_dir).as_posix()
path = candidate.resolve(strict=True)
if not path.is_relative_to(skill_dir):
raise RenderError(f"render source escapes skill directory: {name}")
if not path.is_file():
raise RenderError(f"render source is missing or not a file: {path}")
try:
sources[name] = path.read_text(encoding="utf-8")
except (OSError, UnicodeError) as error:
raise RenderError(f"failed to read render source {path}: {error}") from error
if "workflow.md" not in sources:
raise RenderError(f"render entry is missing: {skill_dir / 'workflow.md'}")
return sources
def _resolve_config_value(value: Any, label: str, project_root: Path) -> str:
text = _require_string(value, label)
if "{project-root}" not in text:
return text
resolved = text.replace("{project-root}", project_root.as_posix())
if not Path(resolved).is_absolute():
raise RenderError(f"{label} must resolve to an absolute path: {resolved}")
return resolved
def _find_config_values(data: Any, key: str, prefix: str = "") -> list[tuple[str, Any]]:
matches: list[tuple[str, Any]] = []
if not isinstance(data, dict):
return matches
for name, value in data.items():
path = f"{prefix}.{name}" if prefix else name
if name == key and not isinstance(value, (dict, list)):
matches.append((path, value))
matches.extend(_find_config_values(value, key, path))
return matches
def _resolve_short_config(central: dict[str, Any], key: str, project_root: Path) -> tuple[str, str]:
matches = _find_config_values(central, key)
if not matches:
raise RenderError(f"missing config value `{key}`")
if len(matches) > 1:
paths = ", ".join(path for path, _ in matches)
raise RenderError(f"ambiguous config value `{key}` found at: {paths}")
path, value = matches[0]
return path, _resolve_config_value(value, f"config.{path}", project_root)
def _format_markdown_list(items: list[str]) -> str:
if not items:
return "_None._"
rendered = []
for item in items:
lines = item.splitlines() or [""]
rendered.append("- " + lines[0])
rendered.extend(" " + line for line in lines[1:])
return "\n".join(rendered)
def _format_review_layers(layers: list[dict[str, str]]) -> str:
active = [layer for layer in layers if layer["instruction"].strip()]
if not active:
return "No active review layers. HALT with blocking condition `no active review layers`."
sections = []
for layer in active:
section = [f"#### {layer['name']} (`{layer['id']}`)"]
if layer.get("when"):
section.extend(["", f"Run only when: {layer['when']}"])
section.extend(["", layer["instruction"].strip()])
sections.append("\n".join(section))
return "\n\n".join(sections)
def _resolve_customization_value(value: Any, default: Any, label: str) -> Any:
"""Validate an effective customization leaf against the shape of its shipped default."""
if isinstance(default, str):
allow_empty = not default.strip()
return _require_string(value, label, allow_empty=allow_empty)
if isinstance(default, list):
if default and all(isinstance(item, dict) for item in default):
return _require_review_layers(value, label)
return _require_string_list(value, label)
if isinstance(default, (bool, int, float, date, time)):
if type(value) is not type(default):
raise RenderError(f"{label} must be {type(default).__name__}, got {type(value).__name__}")
return value
raise RenderError(f"{label} has unsupported default type {type(default).__name__}")
class _Text(str):
"""A customization string. Looping over one is a template mistake, not a walk over its characters."""
def __new__(cls, value: str, label: str) -> _Text:
text = super().__new__(cls, value)
text.label = label
return text
def __iter__(self):
raise RenderError(f"`{self.label}` is a string, not a list")
class _Derived:
"""A name the renderer computes from the central config, when a template reaches it."""
def __init__(self, compute: Callable[[], Any]) -> None:
self.compute = compute
class _MarkdownList(list):
"""A string-list customization; inserted directly it renders as the Markdown list it always did."""
def __str__(self) -> str:
return _format_markdown_list(list(self))
class _LayerList(list):
"""A review-layer customization; inserted directly it renders as lens sections."""
def __str__(self) -> str:
return _format_review_layers(list(self))
def _bind_customization(value: Any, label: str, destination: Path) -> Any:
"""Bind `{skill-root}` in customization prose to the generation and wrap lists for insertion."""
root = destination.as_posix()
if isinstance(value, str):
return _Text(value.replace("{skill-root}", root), label)
if isinstance(value, list):
if value and all(isinstance(item, dict) for item in value):
return _LayerList(
[{key: text.replace("{skill-root}", root) for key, text in layer.items()} for layer in value]
)
return _MarkdownList([item.replace("{skill-root}", root) for item in value])
return value
class _Table:
"""A dotted namespace over a TOML table. Names never hit Python attributes, so `workflow.items` is a lookup."""
def __init__(self, path: str) -> None:
self._path = path
def _child(self, name: str) -> str:
return f"{self._path}.{name}"
def _resolve(self, name: str) -> Any:
raise NotImplementedError
def __getattr__(self, name: str) -> Any:
if name.startswith("_"):
raise AttributeError(name)
return self._resolve(name)
def __getitem__(self, name: Any) -> Any:
if not isinstance(name, str):
raise RenderError(f"`{self._path}` is indexed by name, not {name!r}")
return self._resolve(name)
def __str__(self) -> str:
raise RenderError(f"`{self._path}` is a table, not a value")
class _ConfigTable(_Table):
"""`config.key` is the short lookup of one scalar anywhere in the central config; `config.a.b.c` is a path."""
def __init__(self, central: dict[str, Any], table: dict[str, Any], path: str, ctx: _RenderContext) -> None:
super().__init__(path)
self._central = central
self._table = table
self._ctx = ctx
def _resolve(self, name: str) -> Any:
if self._path == "config" and name not in self._table:
path, resolved = _resolve_short_config(self._central, name, self._ctx.project_root)
self._ctx.inputs[f"config.{path}"] = resolved
return _Text(resolved, f"config.{path}")
label = self._child(name)
if name not in self._table:
raise RenderError(f"missing config value `{label.removeprefix('config.')}`")
value = self._table[name]
if isinstance(value, dict):
return _ConfigTable(self._central, value, label, self._ctx)
resolved = _resolve_config_value(value, label, self._ctx.project_root)
self._ctx.inputs[label] = resolved
return _Text(resolved, label)
class _CustomizationTable(_Table):
"""The effective customization, each leaf validated against its `customize.toml` default."""
def __init__(self, defaults: dict[str, Any] | None, values: dict[str, Any], path: str, ctx: _RenderContext) -> None:
super().__init__(path)
self._defaults = defaults
self._values = values
self._ctx = ctx
def _resolve(self, name: str) -> Any:
path = self._child(name)
if self._defaults is None:
raise RenderError(f"`{path}` requires customize.toml")
if name not in self._defaults:
raise RenderError(f"missing customization parameter `{path}`")
if name not in self._values:
raise RenderError(f"missing customization value `{path}`")
default, value = self._defaults[name], self._values[name]
label = f"customization.{path}"
if isinstance(default, dict):
if not isinstance(value, dict):
raise RenderError(f"{label} must be a table, got {type(value).__name__}")
return _CustomizationTable(default, value, path, self._ctx)
resolved = _resolve_customization_value(value, default, label)
self._ctx.inputs[label] = resolved
return _bind_customization(resolved, label, self._ctx.destination)
class _RenderContext:
"""One rendering pass: the values it serves and the rendered() links each source makes."""
def __init__(
self,
*,
central: dict[str, Any],
defaults: dict[str, Any] | None,
customization: dict[str, Any],
source_names: set[str],
project_root: Path,
destination: Path,
) -> None:
self.project_root = project_root
self.destination = destination
self.inputs: dict[str, Any] = {}
self.links: dict[str, set[str]] = {}
self._source_names = source_names
self.variables = {
"config": _ConfigTable(central, central, "config", self),
"workflow": _CustomizationTable(
None if defaults is None else defaults.get("workflow", {}),
customization.get("workflow", {}),
"workflow",
self,
),
"rendered": self._rendered,
"halt": self._halt,
"initiative_folder": _Derived(self._initiative_folder),
}
self._central = central
def _initiative_folder(self) -> _Text:
"""`core.output_folder`, extended by `/<core.active_initiative>` when an initiative is set."""
folder = str(self.variables["config"].core.output_folder)
core = self._central.get("core")
initiative = core.get("active_initiative") if isinstance(core, dict) else None
if initiative is not None:
folder = f"{folder}/{_require_string(initiative, 'config.core.active_initiative')}"
self.inputs["initiative_folder"] = folder
return _Text(folder, "initiative_folder")
@staticmethod
def _halt(message: Any) -> str:
"""Let a template reject its inputs; the caller prefixes the source and line."""
raise RenderError(str(message))
@jinja2.pass_context
def _rendered(self, context: jinja2.runtime.Context, target: Any) -> str:
if not isinstance(target, str) or target not in self._source_names:
raise RenderError(f"rendered() targets undeclared source: {target}")
self.links.setdefault(context.name or "", set()).add(target)
return (self.destination / target).as_posix()
class _Context(jinja2.runtime.Context):
"""Reaching a derived name computes it, so only the values a template reads key its generation."""
def resolve_or_missing(self, key: str) -> Any:
value = super().resolve_or_missing(key)
return value.compute() if isinstance(value, _Derived) else value
class _SourceLoader(jinja2.BaseLoader):
"""Serve sources by name, and name them so template frames carry `source:line`."""
def __init__(self, sources: dict[str, str]) -> None:
self._sources = sources
def get_source(self, environment: jinja2.Environment, template: str) -> tuple[str, str, Any]:
if template not in self._sources:
raise jinja2.TemplateNotFound(template)
return self._sources[template], template, lambda: True
def _template_location(error: BaseException, source_names: set[str]) -> str | None:
if isinstance(error, jinja2.TemplateSyntaxError):
return f"{error.name}:{error.lineno}" if error.name else None
location = None
traceback = error.__traceback__
while traceback is not None:
filename = traceback.tb_frame.f_code.co_filename
if filename in source_names:
location = f"{filename}:{traceback.tb_lineno}"
traceback = traceback.tb_next
return location
def _render_sources(sources: dict[str, str], skill_dir: Path, context: _RenderContext) -> dict[str, str]:
"""Render every source as a Jinja2 template against the context; return the non-empty outputs."""
# Skill sources name their bundled non-Markdown files (scripts, assets)
# through {skill-root}; those stay in the installed skill directory.
bound = {name: content.replace("{skill-root}", skill_dir.as_posix()) for name, content in sources.items()}
environment = jinja2.Environment(
loader=_SourceLoader(bound),
undefined=jinja2.StrictUndefined,
autoescape=False,
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
environment.context_class = _Context
rendered: dict[str, str] = {}
for name in sources:
try:
rendered[name] = environment.get_template(name).render(context.variables)
except Exception as error:
location = _template_location(error, set(sources))
message = str(error) if isinstance(error, (RenderError, ConfigError, jinja2.TemplateError)) else repr(error)
raise RenderError(f"{location or name}: {message}") from error
# A source whose body renders to nothing is left out; links into it from survivors are broken.
omitted = {name for name, text in rendered.items() if not text.strip()}
if "workflow.md" in omitted:
raise RenderError("workflow.md: rendered empty")
for name in sorted(set(rendered) - omitted):
for target in sorted(context.links.get(name, set()) & omitted):
raise RenderError(f"{name}: rendered() targets omitted source: {target}")
return {name: text for name, text in rendered.items() if name not in omitted}
def _verify_existing(destination: Path, manifest: dict[str, Any]) -> None:
manifest_path = destination / "manifest.json"
try:
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as error:
raise RenderError(f"corrupt existing generation {destination}: {error}") from error
if existing != manifest:
raise RenderError(f"generation collision or corruption at {destination}")
# Only the manifest's own files are verified. Anything else in the folder (Thumbs.db,
# editor swap files, sync conflict copies) is read by nobody and is left alone.
missing = sorted(name for name in manifest["outputs"] if not (destination / name).is_file())
if missing:
raise RenderError(
f"generation is missing rendered files: {', '.join(missing)} in {destination}; "
"deleting that folder is safe because the next run renders it again"
)
for name, expected_hash in manifest["outputs"].items():
try:
actual_hash = _hash_bytes((destination / name).read_bytes())
except OSError as error:
raise RenderError(f"failed to verify {destination / name}: {error}") from error
if actual_hash != expected_hash:
raise RenderError(f"generation output hash mismatch: {destination / name}")
def _publish(destination: Path, outputs: dict[str, bytes], manifest: dict[str, Any]) -> None:
destination.parent.mkdir(parents=True, exist_ok=True)
if destination.exists():
_verify_existing(destination, manifest)
return
# One attempt, not mkdtemp: on Windows, older Pythons' mkdtemp takes "access denied"
# for a name collision and tries the next name, some two billion times. The render
# would hang in a folder it cannot write to instead of halting.
staging = destination.parent / f".staging-{os.urandom(8).hex()}"
staging.mkdir(mode=0o700)
try:
for name, content in outputs.items():
path = staging / name
path.parent.mkdir(parents=True, exist_ok=True)
path.write_bytes(content)
(staging / "manifest.json").write_bytes(
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True, default=_json_scalar).encode("utf-8")
+ b"\n"
)
try:
os.rename(staging, destination)
except OSError:
if destination.exists():
_verify_existing(destination, manifest)
else:
raise
finally:
if staging.exists():
shutil.rmtree(staging, ignore_errors=True)
def render(
project_root: Path, skill_dir: Path, *, overrides: Path | None = None, assignments: list[str] | None = None
) -> Path:
project_root = project_root.resolve(strict=True)
skill_dir = skill_dir.resolve(strict=True)
if not (project_root / "_bmad").is_dir():
raise RenderError(f"project root does not contain _bmad/: {project_root}")
sources = _load_sources(skill_dir)
central = load_central_config(project_root)
has_customization = bool(overrides is not None or assignments) or (skill_dir / "customize.toml").is_file()
defaults = load_toml(skill_dir / "customize.toml", required=True) if has_customization else None
_check_persistent_layers(project_root, skill_dir, defaults)
customization = load_customization(project_root, skill_dir) if has_customization else {}
supplied: set[str] = set()
if defaults is not None:
file_layer, command_layer = _invocation_customization(defaults, overrides, assignments or [])
customization = structural_merge(structural_merge(customization, file_layer), command_layer)
supplied = _leaf_paths(file_layer) | _leaf_paths(command_layer)
source_hashes = {name: _hash_bytes(content.encode("utf-8")) for name, content in sources.items()}
root_hash = _hash_bytes(str(project_root).encode("utf-8"))[:12]
slug = re.sub(r"[^a-z0-9]+", "-", project_root.name.lower()).strip("-") or "project"
slug = slug[:80].rstrip("-") or "project"
namespace = project_root / "_bmad" / "render" / skill_dir.name / f"{slug}-{root_hash}"
def render_pass(destination: Path) -> tuple[_RenderContext, dict[str, str]]:
context = _RenderContext(
central=central,
defaults=defaults,
customization=customization,
source_names=set(sources),
project_root=project_root,
destination=destination,
)
return context, _render_sources(sources, skill_dir, context)
# The generation path is keyed by the values the templates reach, and the
# templates insert that path, so a first pass against a placeholder
# destination collects the inputs and the real pass renders the output.
probe, _ = render_pass(namespace / "pending")
# An override may only name a key some template actually read; values are validated where consumed.
unused = sorted(path for path in supplied if f"customization.{path}" not in probe.inputs)
if unused:
raise RenderError(f"invocation override not used by this render: {', '.join(unused)}")
# Store TOML date/time inputs in the same JSON representation used on disk.
input_values = json.loads(_canonical_json(probe.inputs))
renderer_hash = _hash_bytes(Path(__file__).read_bytes())
identity = {
"project_root": str(project_root),
"skill_root": str(skill_dir),
"renderer_sha256": renderer_hash,
"jinja2_version": jinja2.__version__,
"resolved_values": input_values,
"source_sha256": source_hashes,
}
generation_hash = _hash_bytes(_canonical_json(identity))[:20]
destination = namespace / generation_hash
_, rendered = render_pass(destination)
outputs = {name: content.encode("utf-8") for name, content in rendered.items()}
output_hashes = {name: _hash_bytes(content) for name, content in outputs.items()}
manifest = {
"schema_version": 1,
"skill": skill_dir.name,
"project_root": str(project_root),
"project_slug": slug,
"root_hash": root_hash,
"generation_hash": generation_hash,
"inputs": identity,
"outputs": output_hashes,
}
_publish(destination, outputs, manifest)
return destination / "workflow.md"
def report_owed_setup(skill_dir: Path, project_root: Path) -> None:
# Runs before rendering so the note lands ahead of the instruction to follow,
# and still shows when rendering halts. It must never fail the render.
try:
import setup_check
except Exception:
return
setup_check.report(skill_dir, project_root)
def main() -> int:
parser = _ArgumentParser(description=__doc__)
parser.add_argument("--project-root", required=True)
parser.add_argument("--skill", required=True)
parser.add_argument("--overrides", type=Path, help="invocation-only customization TOML file")
parser.add_argument("--set", dest="assignments", action="append", default=[], metavar="KEY=VALUE")
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
try:
args = parser.parse_args()
report_owed_setup(Path(args.skill).resolve(), Path(args.project_root).resolve())
entry = render(
Path(args.project_root), Path(args.skill), overrides=args.overrides, assignments=args.assignments
)
except (ConfigError, RenderError, OSError, UnicodeError, ValueError) as error:
sys.stdout.write(f"HALT: {' '.join(str(error).splitlines())}\n")
return 1
sys.stdout.write(f"read and follow {entry}\n")
return 0
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
raise SystemExit(main())
@@ -0,0 +1,84 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Resolve BMad's four central TOML layers to JSON."""
import argparse
import json
import sys
from pathlib import Path
# Installed scripts are consumer files, not a location for interpreter caches.
sys.dont_write_bytecode = True
try:
from config_utils import ConfigError, load_central_config
except ModuleNotFoundError as error:
if error.name != "tomllib":
raise
sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n")
raise SystemExit(3) from None
_MISSING = object()
def extract_key(data, dotted_key: str):
current = data
for part in dotted_key.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
return _MISSING
return current
def write_json_stdout(output) -> None:
"""Pin stdout to UTF-8 — a Windows cp1252 default cannot encode emoji icons."""
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
def main() -> int:
parser = argparse.ArgumentParser(description="Resolve BMad central config using four-layer TOML merge.")
parser.add_argument(
"--project-root",
"-p",
required=True,
help="Absolute project root containing _bmad/",
)
parser.add_argument(
"--key",
"-k",
action="append",
default=[],
help="Dotted field path to resolve (repeatable). Omit for full dump.",
)
args = parser.parse_args()
try:
merged = load_central_config(Path(args.project_root).resolve())
except ConfigError as error:
sys.stderr.write(f"error: {error}\n")
return 1
output = merged
if args.key:
output = {}
for key in args.key:
value = extract_key(merged, key)
if value is not _MISSING:
output[key] = value
write_json_stdout(output)
return 0
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
raise SystemExit(main())
@@ -0,0 +1,172 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Resolve a skill's default, team, and user TOML customization layers."""
import argparse
import json
import sys
from pathlib import Path
# Installed scripts are consumer files, not a location for interpreter caches.
sys.dont_write_bytecode = True
try:
from config_utils import ConfigError, load_customization
except ModuleNotFoundError as error:
if error.name != "tomllib":
raise
sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n")
raise SystemExit(3) from None
_MISSING = object()
def find_project_root(start: Path) -> Path | None:
"""Nearest ancestor holding `_bmad/`, falling back to the nearest holding `.git`.
`_bmad/` outranks `.git` at every depth: a submodule or nested repo carries
`.git` without being the BMad project, so treating the two as equal stops the
walk short of the root that owns `_bmad/custom/`.
"""
git_root: Path | None = None
current = start.resolve()
while True:
if (current / "_bmad").is_dir():
return current
if git_root is None and (current / ".git").exists():
git_root = current
if current.parent == current:
return git_root
current = current.parent
def script_project_root() -> Path | None:
"""Project root implied by this script's own install path.
Skills invoke `{project-root}/_bmad/scripts/resolve_customization.py`, so when
this file sits at that path its grandparent is a project root the caller already
resolved.
"""
parents = Path(__file__).resolve().parents
if len(parents) >= 3 and parents[0].name == "scripts" and parents[1].name == "_bmad":
return parents[2]
return None
def candidate_project_roots(skill_dir: Path) -> list[Path]:
"""Plausible project roots, most trustworthy first.
The working directory leads because the project is where the user is working,
not where the skill happens to be installed — a home-installed skill walks up to
`~`, and any `~/_bmad` there would otherwise mask the real project's overrides.
"""
ordered: list[Path] = []
for root in (
find_project_root(Path.cwd()),
script_project_root(),
find_project_root(skill_dir),
):
if root is not None and root not in ordered:
ordered.append(root)
return ordered
def has_override(root: Path, skill_name: str) -> bool:
custom_dir = root / "_bmad" / "custom"
return any((custom_dir / name).is_file() for name in (f"{skill_name}.toml", f"{skill_name}.user.toml"))
def warn_on_masked_override(chosen: Path, rejected: list[Path], skill_name: str) -> None:
"""Break the silence when a real override exists under a root we did not pick."""
if has_override(chosen, skill_name):
return
for root in rejected:
if has_override(root, skill_name):
sys.stderr.write(
f"note: resolved project root {chosen} has no customization for "
f"`{skill_name}`, but {root} does. Using {chosen}; pass "
f"--project-root to select the other explicitly.\n"
)
return
def extract_key(data, dotted_key: str):
current = data
for part in dotted_key.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
return _MISSING
return current
def write_json_stdout(output) -> None:
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
def main() -> int:
parser = argparse.ArgumentParser(description="Resolve skill customization using three-layer TOML merge.")
parser.add_argument("--skill", "-s", required=True, help="Absolute path to the skill directory")
parser.add_argument(
"--project-root",
"-p",
help="Explicit project root containing _bmad/ (recommended)",
)
parser.add_argument(
"--key",
"-k",
action="append",
default=[],
help="Dotted field path to resolve (repeatable). Omit for full dump.",
)
args = parser.parse_args()
skill_dir = Path(args.skill).resolve()
if args.project_root:
project_root = Path(args.project_root).resolve()
else:
candidates = candidate_project_roots(skill_dir)
project_root = candidates[0] if candidates else None
if project_root is not None:
warn_on_masked_override(project_root, candidates[1:], skill_dir.name)
try:
merged = load_customization(project_root, skill_dir)
except ConfigError as error:
sys.stderr.write(f"error: {error}\n")
return 1
output = merged
if args.key:
output = {}
for key in args.key:
value = extract_key(merged, key)
if value is not _MISSING:
output[key] = value
write_json_stdout(output)
report_owed_setup(skill_dir, project_root)
return 0
def report_owed_setup(skill_dir: Path, project_root: Path | None) -> None:
# The resolver is the first thing every skill runs, so it is where a skill
# learns that setup owes it something. It must never fail the resolve.
try:
import setup_check
except Exception:
return
setup_check.report(skill_dir, project_root)
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
raise SystemExit(main())
+225
View File
@@ -0,0 +1,225 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Report the people and groups the installed modules offer.
A module's roster is `roster.toml` beside its `bmod.toml`, in the module
record's folder. It lists members and the groups they form. Nothing is
recorded under `_bmad`: the roster is whatever the installed modules offer
right now, so adding or removing a skill changes it with no setup step.
A member with `skill` is an agent, present only while that skill is installed;
its name, title and icon follow the skill's customization. A member without
`skill` is a guest, available to groups and never part of the default room.
`[agents.<code>]` tables in the central config still apply on top, so a user's
own agents and overrides keep working.
Usage:
uv run roster.py --skill <any installed skill> [--project-root P] [--root R ...]
"""
from __future__ import annotations
import argparse
import json
import sys
import tomllib
from pathlib import Path
sys.dont_write_bytecode = True
from config_utils import ConfigError, load_central_config, load_customization # noqa: E402
from knowledge import ROSTER_NAME, Module, install_command, read_document, scan # noqa: E402
MEMBER_FIELDS = ("name", "icon", "title", "persona", "capabilities", "model")
AGENT_FIELDS = ("name", "icon", "title")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Report the members and groups the installed skills offer.")
parser.add_argument("--skill", type=Path, help="an installed skill; the skills beside it are scanned")
parser.add_argument("--root", type=Path, action="append", default=[], help="a further skills root to scan")
parser.add_argument("--project-root", type=Path, help="project root holding _bmad/, for customization")
args = parser.parse_args(argv)
roots = ([args.skill.resolve().parent] if args.skill else []) + args.root
if not roots:
parser.error("give --skill or --root")
report = collect(roots, args.project_root.resolve() if args.project_root else None)
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
sys.stdout.write(json.dumps(report, indent=2, ensure_ascii=False) + "\n")
return 0
def collect(roots: list[Path], project_root: Path | None = None) -> dict[str, object]:
found = scan(roots)
problems = found.problems
skills = found.folders
files: dict[tuple[str, str], dict[str, object]] = {}
for module in found.modules:
if (module.folder / ROSTER_NAME).exists():
record_file(files, problems, module)
members: dict[str, dict[str, object]] = {}
groups: dict[str, dict[str, object]] = {}
for (code, path), file in sorted(files.items()):
source = file["module"].table.get("update_source")
for member in listed(file["data"], "members", problems, code, path):
add_member(members, problems, member, code, path, source, skills, project_root)
for group in listed(file["data"], "groups", problems, code, path):
add_group(groups, problems, group, code, path)
agents = {code: member for code, member in members.items() if member.get("installed")}
apply_central_agents(agents, members, problems, project_root)
return {
"agents": agents,
"members": members,
"groups": list(groups.values()),
"rosters": [
{"module": code, "path": path, "skills": [name for name in file["module"].skills if name in skills]}
for (code, path), file in sorted(files.items())
],
"problems": problems,
}
def listed(data: dict[str, object], key: str, problems: list[dict[str, object]], module: str, path: str) -> list:
found = data.get(key, [])
if isinstance(found, list):
return found
problems.append({"kind": "roster", "problem": f"{module} {path}: '{key}' is not a list"})
return []
def record_file(
files: dict[tuple[str, str], dict[str, object]], problems: list[dict[str, object]], module: Module
) -> None:
folder = module.folder
path = folder / ROSTER_NAME
try:
data = tomllib.loads(read_document(path, folder).decode("utf-8"))
except (OSError, ValueError, UnicodeError, tomllib.TOMLDecodeError) as error:
problems.append({"kind": "roster", "skill": folder.name, "problem": f"{path}: {error}"})
return
files.setdefault((module.code, ROSTER_NAME), {"module": module, "data": data})
def add_member(
members: dict[str, dict[str, object]],
problems: list[dict[str, object]],
member: object,
module: str,
path: str,
source: object,
skills: dict[str, Path],
project_root: Path | None,
) -> None:
code = member.get("code") if isinstance(member, dict) else None
if not isinstance(code, str) or not code:
problems.append({"kind": "member", "problem": f"{module} {path}: a member has no code"})
return
if code in members:
problems.append(
{
"kind": "member",
"problem": f"{module} {path}: member {code!r} is already defined by {members[code]['module']}",
}
)
return
entry: dict[str, object] = {"code": code, "module": module, "source": "roster"}
for field in MEMBER_FIELDS:
if isinstance(member.get(field), str):
entry[field] = member[field]
skill = member.get("skill")
if isinstance(skill, str) and skill:
entry["skill"] = skill
entry["installed"] = skill in skills
if entry["installed"]:
entry.update(agent_identity(skills[skill], project_root))
else:
command = install_command(source, skill)
if command:
entry["install"] = command
entry.setdefault("name", code)
members[code] = entry
def agent_identity(skill_dir: Path, project_root: Path | None) -> dict[str, str]:
"""The name, title and icon the agent actually answers to, after any customization."""
try:
agent = load_customization(project_root, skill_dir).get("agent", {})
except (ConfigError, OSError):
return {}
if not isinstance(agent, dict):
return {}
return {field: agent[field] for field in AGENT_FIELDS if isinstance(agent.get(field), str) and agent[field]}
def add_group(
groups: dict[str, dict[str, object]], problems: list[dict[str, object]], group: object, module: str, path: str
) -> None:
group_id = group.get("id") if isinstance(group, dict) else None
if not isinstance(group_id, str) or not group_id:
problems.append({"kind": "group", "problem": f"{module} {path}: a group has no id"})
return
if group_id in groups:
problems.append(
{
"kind": "group",
"problem": f"{module} {path}: group {group_id!r} is already defined by {groups[group_id]['module']}",
}
)
return
groups[group_id] = {**group, "module": module}
def apply_central_agents(
agents: dict[str, dict[str, object]],
members: dict[str, dict[str, object]],
problems: list[dict[str, object]],
project_root: Path | None,
) -> None:
"""Lay the central config's [agents.<code>] tables over the scan.
This is how a user adds an agent of their own or describes one further, and
how an install made before rosters existed keeps the agents it recorded.
An entry for a roster agent whose skill is gone is skipped: the old
installer recorded it and nothing removed it when the skill went. For a
roster agent the roster and the skill's customization decide name, title,
icon and module, so a recorded default never undoes a customized name.
"""
if project_root is None or not (project_root / "_bmad").is_dir():
return
try:
configured = load_central_config(project_root).get("agents", {})
except (ConfigError, OSError) as error:
problems.append({"kind": "config", "problem": str(error)})
return
if not isinstance(configured, dict):
return
for code, info in configured.items():
if not isinstance(info, dict) or members.get(code, {}).get("installed") is False:
continue
entry = agents.setdefault(code, {"code": code, "source": "config"})
settled = (
{"module", *(field for field in AGENT_FIELDS if field in entry)} if entry["source"] == "roster" else set()
)
for field, value in info.items():
if field in settled:
continue
# Older installs recorded the persona paragraph as `description`.
target = "persona" if field == "description" and "persona" not in info else field
entry[target] = value
entry.setdefault("name", code)
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.exit(main())
File diff suppressed because it is too large Load Diff
+142
View File
@@ -0,0 +1,142 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Tell a starting skill what setup still owes it.
A skill can be installed after the last `bmad setup`, without its module
record, or need a newer hub than the one present. Nothing is recorded to detect
that: everything here is read from the skill's own `bmod.toml`, its module's
`bmod.toml` beside it, the skills installed beside it, and `_bmad/`.
Only what stops a skill from working well is reported, because this runs every
time a skill starts. Recommended skills belong to setup, status, and help.
"""
from __future__ import annotations
import sys
from pathlib import Path
sys.dont_write_bytecode = True
MANIFEST_NAME = "bmod.toml"
_MISSING = object()
def install_command(source: str, skill: str) -> str:
import setup as hub
command = hub.install_command(source, skill)
return f"`{command}`" if command is not None else f"`{skill}` from {source}"
def owed(skill_dir: Path, project_root: Path | None) -> list[str]:
"""Plain sentences for the agent to relay; empty when nothing is owed."""
import setup as hub
from config_utils import ConfigError, load_central_config
parsed = hub.read_bmod_file(skill_dir / MANIFEST_NAME)
if parsed is None or parsed.skill is None:
return []
skill = parsed.skill
notes: list[str] = []
record = parsed.bmod
if record is None:
other = hub.read_bmod_file(skill_dir.parent / skill.bmod / MANIFEST_NAME)
record = other.bmod if other is not None else None
if record is None:
notes.append(
f"belongs to a module whose record `{skill.bmod}` is not installed beside it. "
f"Offer to install it with {install_command(skill.source, skill.bmod)}."
)
own_source = skill.source if skill.source is not None else record.update_source if record else ""
declared = [(requirement, record.update_source) for requirement in (record.required_skills if record else ())]
declared += [(requirement, own_source) for requirement in skill.required_skills]
# A skill named by both lists is checked once, against the higher minimum.
wanted: dict[str, tuple] = {}
for requirement, default_source in declared:
if requirement.skill == skill_dir.name:
continue
kept = wanted.get(requirement.skill)
if kept is None or (
requirement.version is not None
and (kept[0].version is None or (hub.compare_semver(requirement.version, kept[0].version) or 0) > 0)
):
wanted[requirement.skill] = (requirement, default_source)
for requirement, default_source in wanted.values():
state, installed = hub.requirement_check(skill_dir.parent, requirement)
if state == "missing":
source = requirement.source or default_source
notes.append(
f"needs the `{requirement.skill}` skill, which is not installed beside it. "
f"If you have no `{requirement.skill}` skill, offer to install it with "
f"{install_command(source, requirement.skill)}."
)
elif state == "outdated":
notes.append(
f"needs `{requirement.skill}` {requirement.version} or later, and {installed} is installed. "
"Offer to run `npx skills update`."
)
elif state == "unknown-version":
notes.append(
f"needs `{requirement.skill}` {requirement.version} or later, and the installed copy's version "
"cannot be read. Offer to run `npx skills update`."
)
if record is None or project_root is None or not (project_root / "_bmad").is_dir():
return notes
if record.questions:
try:
config = load_central_config(project_root)
except ConfigError:
config = None
if config is not None:
unanswered = [
question.key
for question in record.questions
if lookup(config, ("modules", question.module, *question.key.split("."))) is _MISSING
]
if unanswered:
notes.append(
f"belongs to module `{record.code}`, whose setup questions were never answered "
f"({', '.join(unanswered)}). Offer to run `bmad setup`."
)
installed_scripts = project_root / "_bmad" / record.code / "scripts"
for relative in skill.scripts:
packaged = skill_dir.joinpath(*relative.parts)
placed = installed_scripts.joinpath(*relative.parts[1:])
if not placed.is_file() or (packaged.is_file() and placed.read_bytes() != packaged.read_bytes()):
notes.append(
f"belongs to module `{record.code}`, whose scripts in `_bmad/{record.code}/scripts/` are "
"missing or out of date. Offer to run `bmad setup`."
)
break
return notes
def lookup(data: object, keys: tuple[str, ...]) -> object:
current = data
for key in keys:
if not isinstance(current, dict) or key not in current:
return _MISSING
current = current[key]
return current
def report(skill_dir: Path, project_root: Path | None) -> None:
"""Write what is owed to stderr, worded as an instruction so no skill has to explain it.
A failure here must never stop the skill from resolving.
"""
try:
notes = owed(skill_dir, project_root)
except Exception:
return
for note in notes:
sys.stderr.write(f"setup: before continuing, tell the user that `{skill_dir.name}` {note}\n")
@@ -0,0 +1,143 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Release version stamper for module repositories.
Writes a human-supplied SemVer version into every module record: the
`version` line inside the `[bmod]` table of each skills/*/bmod.toml that has
one. Member skills carry no version and are not written. Each module
repository's release runbook uses it to stamp releases and the next
placeholder on `dev`.
Before writing anything it runs the repository checks in
validate_manifests.py beside it, the same ones pre-commit and CI run.
`--check` runs only those checks and writes nothing.
A file may carry keys and tables this script does not know. The runtime
ignores them, so a release must not refuse them; they are left exactly as
written. The version line is rewritten textually, so nothing else in a file
moves, and each new file is parsed and compared before anything is written.
After writing, the script re-reads every file and fails naming the offending
path if anything is off.
Usage:
uv run --python 3.11 skills/bmad/scripts/stamp_release.py 1.2.0 [--project-root <path>]
uv run --python 3.11 skills/bmad/scripts/stamp_release.py --check [--project-root <path>]
"""
from __future__ import annotations
import argparse
import importlib.util
import sys
import tomllib
from pathlib import Path
sys.dont_write_bytecode = True
class StampError(Exception):
pass
def load_validator():
"""The repository checks, so a release and a commit can never be held to different rules."""
path = Path(__file__).resolve().parent / "validate_manifests.py"
spec = importlib.util.spec_from_file_location("bmad_validate_for_stamp", path)
if spec is None or spec.loader is None:
raise StampError(f"cannot load the repository checks at {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
validator = load_validator()
setup = validator.setup
def validate_version(version: str) -> None:
problem = validator.version_problem(version)
if problem is not None:
raise StampError(problem)
def collect_records(project_root: Path) -> list[Path]:
report = validator.check_repo(project_root)
if report.problems:
raise StampError("\n ".join(report.problems))
return list(report.records)
def read_toml(path: Path, rel: str) -> tuple[str, dict[str, object]]:
try:
text = path.read_bytes().decode("utf-8")
return text, tomllib.loads(text)
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
raise StampError(f"{rel}: cannot read bmod file: {error}") from error
def verify_stamp(root: Path, records: list[Path], expected: dict[str, dict[str, object]]) -> None:
for record in records:
rel = record.relative_to(root).as_posix()
_, data = read_toml(record, rel)
if data != expected[rel]:
raise StampError(f"{rel}: stamping changed something other than the version")
collect_records(root)
def run(project_root: Path, version: str) -> int:
try:
validate_version(version)
records = collect_records(project_root)
# Nothing is written if any file fails.
planned: list[tuple[Path, str]] = []
expected: dict[str, dict[str, object]] = {}
for record in records:
rel = record.relative_to(project_root).as_posix()
original, data = read_toml(record, rel)
try:
planned.append((record, validator.stamp_text(original, version)))
except ValueError as error:
raise StampError(f"{rel}: {error}") from error
expected[rel] = validator.with_version(data, version)
for path, content in planned:
try:
path.write_bytes(content.encode("utf-8"))
except OSError as error:
raise StampError(f"{path.relative_to(project_root).as_posix()}: cannot write: {error}") from error
verify_stamp(project_root, records, expected)
except StampError as error:
print(f"Error: {error}", file=sys.stderr)
return 1
print(f"Stamped version {version} into {len(planned)} files:")
for path, _ in planned:
print(f" {path.relative_to(project_root).as_posix()}")
return 0
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Stamp a version into every module record.")
parser.add_argument("version", nargs="?", help='SemVer release version, e.g. "6.12.0"')
parser.add_argument("--check", action="store_true", help="run the repository checks and write nothing")
parser.add_argument(
"--project-root", type=Path, default=Path.cwd(), help="repository to stamp (default: the current directory)"
)
args = parser.parse_args(argv)
if args.check == (args.version is not None):
parser.error("give a version to stamp, or --check, but not both")
if args.check:
return validator.main(["--project-root", str(args.project_root)])
return run(args.project_root.resolve(), args.version)
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.exit(main())
@@ -0,0 +1,90 @@
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from config_utils import ( # noqa: E402
ConfigError,
load_central_config,
load_customization,
load_toml,
structural_merge,
)
class ConfigUtilsTests(unittest.TestCase):
def test_structural_merge_recurses_appends_and_replaces_keyed_tables(self):
base = {
"nested": {"keep": True, "replace": "old"},
"plain": ["base"],
"items": [{"id": "one", "value": "old"}],
}
override = {
"nested": {"replace": "new"},
"plain": ["override"],
"items": [
{"id": "one", "value": "new"},
{"id": "two", "value": "added"},
],
}
merged = structural_merge(base, override)
self.assertEqual(merged["nested"], {"keep": True, "replace": "new"})
self.assertEqual(merged["plain"], ["base", "override"])
self.assertEqual(
merged["items"],
[
{"id": "one", "value": "new"},
{"id": "two", "value": "added"},
],
)
def test_non_string_keyed_identifier_is_rejected(self):
with self.assertRaisesRegex(ConfigError, "identifier `id` must be a string"):
structural_merge([{"id": "valid"}], [{"id": 42}])
def test_present_malformed_optional_layer_is_rejected(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "optional.toml"
path.write_text("[broken\n", encoding="utf-8")
with self.assertRaisesRegex(ConfigError, "failed to parse"):
load_toml(path)
def test_missing_optional_layer_is_empty(self):
with tempfile.TemporaryDirectory() as temp_dir:
path = Path(temp_dir) / "optional.toml"
self.assertEqual(load_toml(path), {})
def test_filesystem_layer_precedence(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
bmad = root / "_bmad"
custom = bmad / "custom"
skill = bmad / "bmm" / "sample-skill"
custom.mkdir(parents=True)
skill.mkdir(parents=True)
(bmad / "config.toml").write_text('[value]\norder = "base-team"\n', encoding="utf-8")
(bmad / "config.user.toml").write_text(
'[value]\norder = "base-user"\nstray = "ignored"\n', encoding="utf-8"
)
(custom / "config.toml").write_text('[value]\norder = "custom-team"\n', encoding="utf-8")
(custom / "config.user.toml").write_text('[value]\norder = "custom-user"\n', encoding="utf-8")
(skill / "customize.toml").write_text('[value]\norder = "default"\n', encoding="utf-8")
(custom / "sample-skill.toml").write_text('[value]\norder = "team"\n', encoding="utf-8")
(custom / "sample-skill.user.toml").write_text('[value]\norder = "user"\n', encoding="utf-8")
merged = load_central_config(root)
self.assertEqual(merged["value"]["order"], "custom-user")
# _bmad/config.user.toml is old-installer debris (setup.py's
# LEGACY_LEFTOVERS), not a layer. Nothing writes it; nothing reads it.
self.assertNotIn("stray", merged["value"])
self.assertEqual(load_customization(root, skill)["value"]["order"], "user")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,151 @@
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import knowledge # noqa: E402
SOURCE = "github:acme/tools/skills"
MIGRATION = """
[migration]
module = "demo"
from = "1"
to = "2"
title = "Move demo artifacts to the v2 layout"
summary = "v1 to v2"
detect = "a v1 folder"
guide = "move it"
checklist = ["it moved"]
"""
def write_module(root: Path, skills: tuple[str, ...] = (), *, code: str = "demo") -> Path:
record = root / f"bmod-{code}"
record.mkdir(parents=True)
names = ", ".join(f'"{name}"' for name in skills)
lines = ["[bmod]", f'code = "{code}"', 'version = "1.0.0"', f'update_source = "{SOURCE}"', f"skills = [{names}]"]
(record / "bmod.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
for name in skills:
folder = root / name
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{record.name}"\nsource = "{SOURCE}"\n', encoding="utf-8")
return record
class MigrationTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.skills = Path(self.temp.name) / ".claude" / "skills"
def test_a_migration_file_is_listed_by_its_table_without_its_text(self):
record = write_module(self.skills, ("demo-one",))
(record / "migration-1.toml").write_text(MIGRATION, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(
report["migrations"],
[
{
"module": "demo",
"path": "migration-1.toml",
"file": str(record / "migration-1.toml"),
"from": "1",
"to": "2",
"title": "Move demo artifacts to the v2 layout",
}
],
)
self.assertEqual(report["problems"], [])
def test_other_toml_files_beside_the_record_are_not_migrations_and_a_bad_key_is_named(self):
record = write_module(self.skills)
(record / "roster.toml").write_text("[[members]\nnot even toml\n", encoding="utf-8")
(record / "notes.toml").write_text('migration = "not a table"\n', encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertEqual([problem["kind"] for problem in report["problems"]], ["migration"])
self.assertIn("'migration' is not a table", report["problems"][0]["problem"])
def test_a_migration_without_its_fields_is_a_problem_not_a_listing(self):
record = write_module(self.skills)
(record / "migration-2.toml").write_text('[migration]\nmodule = "demo"\nfrom = 1\nto = "2"\n', encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertEqual(len(report["problems"]), 1)
self.assertEqual(report["problems"][0]["kind"], "migration")
self.assertIn(
"needs non-empty from, title, summary, detect, guide, checklist", report["problems"][0]["problem"]
)
def test_whitespace_only_fields_and_blank_checklist_items_are_missing(self):
record = write_module(self.skills)
text = MIGRATION.replace('guide = "move it"', 'guide = " "').replace(
'checklist = ["it moved"]', 'checklist = [" "]'
)
(record / "migration-3.toml").write_text(text, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertIn("needs non-empty guide, checklist", report["problems"][0]["problem"])
def test_a_migration_for_another_module_is_a_problem(self):
record = write_module(self.skills)
text = MIGRATION.replace('module = "demo"', 'module = "other"')
(record / "migration-4.toml").write_text(text, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertIn("is not this record's 'demo'", report["problems"][0]["problem"])
def test_migrations_are_listed_by_number_not_by_text(self):
record = write_module(self.skills)
for number in (10, 2, 1):
text = MIGRATION.replace('from = "1"', f'from = "{number}"').replace('to = "2"', f'to = "{number + 1}"')
(record / f"migration-{number}.toml").write_text(text, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(
[m["path"] for m in report["migrations"]], ["migration-1.toml", "migration-2.toml", "migration-10.toml"]
)
self.assertEqual(report["problems"], [])
def test_a_migration_outside_the_naming_rule_is_a_problem(self):
record = write_module(self.skills)
for name in ("v1-v2-migration.toml", "migration-one.toml", "migration-1-2.toml"):
(record / name).write_text(MIGRATION, encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertEqual(len(report["problems"]), 3)
for problem in report["problems"]:
self.assertIn("must be named migration-<n>.toml", problem["problem"])
def test_two_migrations_of_one_module_with_the_same_number_are_problems(self):
record = write_module(self.skills)
other = write_module(self.skills, code="other")
(record / "migration-1.toml").write_text(MIGRATION, encoding="utf-8")
(record / "migration-01.toml").write_text(MIGRATION, encoding="utf-8")
(record / "migration-2.toml").write_text(MIGRATION, encoding="utf-8")
(other / "migration-1.toml").write_text(
MIGRATION.replace('module = "demo"', 'module = "other"'), encoding="utf-8"
)
report = knowledge.collect([self.skills])
self.assertEqual(
[(m["module"], m["path"]) for m in report["migrations"]],
[("demo", "migration-2.toml"), ("other", "migration-1.toml")],
)
self.assertEqual(
sorted(Path(problem["document"]).name for problem in report["problems"]),
["migration-01.toml", "migration-1.toml"],
)
for problem in report["problems"]:
self.assertIn("same number", problem["problem"])
def test_a_migration_that_is_not_toml_is_a_problem(self):
record = write_module(self.skills)
(record / "migration-5.toml").write_text('[migration\nfrom = "1"\n', encoding="utf-8")
report = knowledge.collect([self.skills])
self.assertEqual(report["migrations"], [])
self.assertEqual([problem["kind"] for problem in report["problems"]], ["migration"])
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,365 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["pytest>=8.0"]
# ///
"""Tests for memlog.py. Run: uv run --with pytest pytest scripts/tests/test_memlog.py
The spine under test is the flat, append-only, chronological invariant: every entry is
one line recorded at the end in the order it happened — no sections, no grouping, and no
lifecycle status the log would have to mutate.
"""
import json
import subprocess
import sys
from pathlib import Path
import pytest
SCRIPT = Path(__file__).resolve().parent.parent / "memlog.py"
sys.path.insert(0, str(SCRIPT.parent))
import memlog # noqa: E402
MEMLOG = ".memlog.md"
@pytest.fixture
def ws(tmp_path):
return str(tmp_path)
def read(ws):
return (Path(ws) / MEMLOG).read_text(encoding="utf-8")
def body_of(ws):
return memlog.split(read(ws))[1]
def entries(ws):
return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")]
def init(ws, **fields):
fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"}
argv = ["init", "--workspace", ws]
for k, v in fields.items():
argv += ["--field", f"{k}={v}"]
assert memlog.main(argv) == 0
def append(ws, text, entry_type=None, by=None):
argv = ["append", "--workspace", ws, "--text", text]
if entry_type:
argv += ["--type", entry_type]
if by:
argv += ["--by", by]
assert memlog.main(argv) == 0
# --- init ---------------------------------------------------------------
def test_init_writes_frontmatter_fields(ws):
init(ws)
meta, body = memlog.split(read(ws))
assert meta["topic"] == "Reinvent the lunchbox"
assert meta["goal"] == "ideas for a pitch"
assert "updated" in meta
assert body.strip() == ""
def test_init_has_no_lifecycle_status(ws):
# A memory log carries no "status" flag; completion is an appended entry, not frontmatter.
init(ws)
meta, _ = memlog.split(read(ws))
assert "status" not in meta
def test_init_arbitrary_fields(ws):
init(ws, topic="T", audience="board")
meta, _ = memlog.split(read(ws))
assert meta["audience"] == "board"
def test_init_refuses_overwrite(ws):
init(ws)
assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2
def test_init_creates_missing_workspace(tmp_path):
nested = str(tmp_path / "a" / "b")
assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0
assert (Path(nested) / MEMLOG).is_file()
def test_init_rejects_malformed_field(ws):
assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2
# --- addressing: --workspace and --path are interchangeable --------------
def test_path_addressing_targets_the_file_directly(tmp_path):
target = tmp_path / "run" / ".memlog.md"
assert memlog.main(["init", "--path", str(target), "--field", "topic=T"]) == 0
assert target.is_file()
assert memlog.main(["append", "--path", str(target), "--text", "an idea", "--type", "idea"]) == 0
body = memlog.split(target.read_text(encoding="utf-8"))[1]
assert "- (idea) an idea" in body
def test_workspace_and_path_resolve_to_same_file(ws):
init(ws)
via_path = str(Path(ws) / MEMLOG)
assert memlog.main(["append", "--path", via_path, "--text", "from path"]) == 0
assert memlog.main(["append", "--workspace", ws, "--text", "from workspace"]) == 0
assert entries(ws) == ["- from path", "- from workspace"]
def test_target_is_required(ws):
with pytest.raises(SystemExit):
memlog.main(["append", "--text", "orphan"]) # neither --workspace nor --path
# --- append: flat chronological order is the whole point -----------------
def test_append_lands_at_end_in_order(ws):
init(ws)
append(ws, "first")
append(ws, "second")
append(ws, "third")
assert entries(ws) == ["- first", "- second", "- third"]
def test_no_sections_or_headings_ever(ws):
init(ws)
append(ws, "started foo", entry_type="technique")
append(ws, "an idea", entry_type="idea")
append(ws, "started bar", entry_type="technique")
assert "## " not in body_of(ws) # the flat log never grows headings
def test_type_renders_as_inline_tag(ws):
init(ws)
append(ws, "the earth revolves around the sun", entry_type="idea")
append(ws, "how do we handle stampede?", entry_type="question")
body = body_of(ws)
assert "- (idea) the earth revolves around the sun" in body
assert "- (question) how do we handle stampede?" in body
def test_append_without_type_is_plain_note(ws):
init(ws)
append(ws, "bare entry")
assert entries(ws) == ["- bare entry"]
def test_completion_is_an_entry_not_a_status(ws):
# The documented way to mark a session done: append it. Frontmatter never gains a status.
init(ws)
append(ws, "session complete", entry_type="event")
meta, _ = memlog.split(read(ws))
assert "status" not in meta
assert entries(ws)[-1] == "- (event) session complete"
def test_append_collapses_newlines_into_one_line(ws):
init(ws)
append(ws, "line one\nline two\n spaced out")
assert entries(ws) == ["- line one line two spaced out"]
def test_revisited_technique_is_just_a_later_entry(ws):
# the user's model: switching techniques is an entry, not a section to return to
init(ws)
append(ws, "started SCAMPER", entry_type="technique")
append(ws, "magnetic latch", entry_type="idea")
append(ws, "started Six Hats", entry_type="technique")
append(ws, "stale data risk", entry_type="idea")
append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again
append(ws, "stackable tiers", entry_type="idea")
assert entries(ws) == [
"- (technique) started SCAMPER",
"- (idea) magnetic latch",
"- (technique) started Six Hats",
"- (idea) stale data risk",
"- (technique) started SCAMPER",
"- (idea) stackable tiers",
]
def test_by_renders_attribution_in_tag(ws):
# Creative Partner mode must record whose idea each one was
init(ws)
append(ws, "magnetic latch lid", entry_type="idea", by="user")
append(ws, "lid doubles as a plate", entry_type="idea", by="coach")
body = body_of(ws)
assert "- (idea by user) magnetic latch lid" in body
assert "- (idea by coach) lid doubles as a plate" in body
def test_by_without_type_renders_alone(ws):
init(ws)
append(ws, "off-the-cuff thought", by="coach")
assert entries(ws) == ["- (by coach) off-the-cuff thought"]
def test_heterogeneous_entry_types_coexist(ws):
init(ws)
append(ws, "an idea", entry_type="idea")
append(ws, "an open question", entry_type="question")
append(ws, "a decision we made", entry_type="decision")
append(ws, "user wants mobile-first", entry_type="direction")
body = body_of(ws)
for tag in ("(idea)", "(question)", "(decision)", "(direction)"):
assert tag in body
def test_free_vocabulary_is_not_enforced(ws):
# The tool is neutral: any --type the host skill names renders verbatim.
init(ws)
append(ws, "a custom kind", entry_type="crack")
append(ws, "another", entry_type="lock")
body = body_of(ws)
assert "- (crack) a custom kind" in body
assert "- (lock) another" in body
# --- set: generic descriptive frontmatter, no lifecycle semantics --------
def test_set_adds_field(ws):
init(ws)
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
assert memlog.split(read(ws))[0]["mode"] == "partner"
def test_set_replaces_field(ws):
init(ws, topic="T", mode="facilitator")
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
assert memlog.split(read(ws))[0]["mode"] == "partner"
def test_set_preserves_body(ws):
init(ws)
append(ws, "keep me", entry_type="idea")
memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
meta, body = memlog.split(read(ws))
assert meta["mode"] == "partner"
assert "- (idea) keep me" in body
def test_updated_stays_last(ws):
init(ws)
memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"])
meta = memlog.split(read(ws))[0]
assert list(meta)[-1] == "updated"
# --- robustness ---------------------------------------------------------
def test_roundtrip_render_is_stable(ws):
init(ws)
append(ws, "one", entry_type="idea")
meta, body = memlog.split(read(ws))
assert memlog.split(memlog.render(meta, body)) == (meta, body)
def test_commas_in_field_survive(ws):
init(ws, topic="cars, trains, and planes")
append(ws, "z", entry_type="idea")
meta, _ = memlog.split(read(ws))
assert meta["topic"] == "cars, trains, and planes"
def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws):
# A `---` inside a value must NOT be read as the closing fence: topic stays intact
# and the body never leaks frontmatter text.
init(ws, topic="Pricing --- tiers --- and add-ons")
append(ws, "an idea", entry_type="idea")
meta, body = memlog.split(read(ws))
assert meta["topic"] == "Pricing --- tiers --- and add-ons"
assert entries(ws) == ["- (idea) an idea"]
assert "topic:" not in body # frontmatter never bled into the body
def test_newline_in_field_is_neutralized(ws):
# A value carrying a newline can't break the fence on the next round-trip.
memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"])
append(ws, "x", entry_type="idea")
meta, _ = memlog.split(read(ws))
assert "\n" not in meta["topic"]
def test_append_emits_json_ack(ws, capsys):
init(ws)
append(ws, "x", entry_type="idea")
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert out["ok"] is True
assert out["entries"] == 1
assert out["memlog"].endswith(MEMLOG)
assert "status" not in out # no lifecycle status
assert "section" not in out # sections are gone
def test_ack_entry_count_climbs(ws, capsys):
init(ws)
append(ws, "a")
append(ws, "b")
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert out["entries"] == 2
# --- append writes only its own line ------------------------------------
def test_first_entry_after_init(ws):
init(ws)
append(ws, "first", entry_type="idea")
meta, _ = memlog.split(read(ws))
assert meta["topic"] == "Reinvent the lunchbox"
assert entries(ws) == ["- (idea) first"]
def test_append_does_not_restamp_updated(ws):
init(ws)
path = Path(ws) / MEMLOG
path.write_text(read(ws).replace(memlog.split(read(ws))[0]["updated"], "2000-01-01T00:00"), encoding="utf-8")
append(ws, "a")
append(ws, "b")
assert memlog.split(read(ws))[0]["updated"] == "2000-01-01T00:00"
assert entries(ws) == ["- a", "- b"]
def test_append_to_missing_log_fails_and_creates_nothing(ws):
with pytest.raises(FileNotFoundError):
memlog.main(["append", "--workspace", ws, "--text", "orphan"])
assert not (Path(ws) / MEMLOG).exists()
def test_append_after_missing_trailing_newline_starts_new_line(ws):
init(ws)
append(ws, "first")
path = Path(ws) / MEMLOG
path.write_text(read(ws).rstrip("\n"), encoding="utf-8")
append(ws, "second")
assert entries(ws) == ["- first", "- second"]
def test_parallel_appends_all_land(ws):
init(ws)
n = 40
procs = [
subprocess.Popen(
[sys.executable, str(SCRIPT), "append", "--workspace", ws, "--text", f"entry {i}"],
stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE,
)
for i in range(n)
]
failures = [p.stderr.read().decode() for p in procs if p.wait() != 0]
assert failures == []
assert sorted(entries(ws)) == sorted(f"- entry {i}" for i in range(n))
@@ -0,0 +1,976 @@
"""Snapshot renderer tests against the current install layout and shipped skills.
Host skills live outside `_bmad/`. `_bmad/` is the project runtime setup
materializes: shared scripts, team config, custom overlays, and published
snapshots. Call `render()` for the success path. Use the installed CLI for
the agent-facing dispatch/HALT contract and for anything that needs a
separate process.
"""
from __future__ import annotations
import hashlib
import json
import os
import re
import shutil
import subprocess
import sys
import tempfile
import tomllib
import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
from helpers import chmod
SCRIPTS_SRC = Path(__file__).resolve().parents[1]
REPO = SCRIPTS_SRC.parents[2]
SKILLS_SRC = REPO / "skills"
CONFIG_TEMPLATE = (SCRIPTS_SRC.parent / "assets" / "config.template.toml").read_text(encoding="utf-8")
SHARED_SCRIPTS = (
"config_utils.py",
"memlog.py",
"render_skill.py",
"resolve_config.py",
"resolve_customization.py",
)
SHIPPED_SKILLS = ("bmad-build-auto", "bmad-build", "bmad-code-review")
RENDERED_SKILLS = (*SHIPPED_SKILLS, "bmad-walkthrough", "bmad-retrospective", "bmad-qa-generate-e2e-tests")
COMPILE_TOKEN = re.compile(r"\{\{\s*(?:config|workflow)\.|\{\{\s*(?:rendered\(|initiative_folder)|\{%")
DISPATCH_PREFIX = "read and follow "
sys.path.insert(0, str(SCRIPTS_SRC))
import render_skill as rs # noqa: E402
def _team_config(project: Path) -> str:
return CONFIG_TEMPLATE.replace("{directory_name}", project.name)
def _copy_skill(dest: Path, name: str) -> Path:
shutil.copytree(
SKILLS_SRC / name,
dest,
ignore=shutil.ignore_patterns("__pycache__", "*.pyc"),
)
return dest
def _files(directory: Path) -> dict[str, bytes]:
files = {
path.relative_to(directory).as_posix(): path.read_bytes() for path in directory.rglob("*") if path.is_file()
}
return dict(sorted(files.items()))
def _markdown(directory: Path) -> str:
return "\n".join(content.decode("utf-8") for name, content in _files(directory).items() if name.endswith(".md"))
def _namespace_dir(project: Path, skill_name: str) -> Path:
root = str(project.resolve())
slug = re.sub(r"[^a-z0-9]+", "-", project.name.lower()).strip("-") or "project"
slug = slug[:80].rstrip("-") or "project"
root_hash = hashlib.sha256(root.encode("utf-8")).hexdigest()[:12]
return project / "_bmad" / "render" / skill_name / f"{slug}-{root_hash}"
class PublishInternalsTests(unittest.TestCase):
"""Corruption and reuse branches of `_publish` without rendering a whole skill."""
def test_identical_publish_reuses_and_rejects_each_corruption_mode(self):
with tempfile.TemporaryDirectory() as temp_dir:
dest = Path(temp_dir) / "generation"
outputs = {"workflow.md": b"hello\n"}
manifest = {
"schema_version": 1,
"outputs": {"workflow.md": rs._hash_bytes(b"hello\n")},
}
rs._publish(dest, outputs, manifest)
rs._publish(dest, outputs, manifest)
self.assertEqual((dest / "workflow.md").read_bytes(), b"hello\n")
with self.assertRaisesRegex(rs.RenderError, "collision or corruption"):
rs._publish(dest, outputs, {**manifest, "extra": True})
(dest / "Thumbs.db").write_bytes(b"stray")
rs._publish(dest, outputs, manifest)
self.assertEqual((dest / "Thumbs.db").read_bytes(), b"stray")
(dest / "workflow.md").unlink()
with self.assertRaisesRegex(
rs.RenderError, rf"missing rendered files: workflow\.md in {re.escape(str(dest))}"
):
rs._publish(dest, outputs, manifest)
(dest / "workflow.md").write_bytes(b"hello\ncorrupt")
with self.assertRaisesRegex(rs.RenderError, "hash mismatch"):
rs._publish(dest, outputs, manifest)
(dest / "workflow.md").write_bytes(b"hello\n")
(dest / "manifest.json").write_text("{", encoding="utf-8")
with self.assertRaisesRegex(rs.RenderError, "corrupt existing"):
rs._publish(dest, outputs, manifest)
class RenderSkillTests(unittest.TestCase):
def _workspace(
self,
*,
name: str = "project",
shared_bmad: Path | None = None,
config: str | None = None,
) -> SimpleNamespace:
outer = Path(tempfile.mkdtemp(prefix="bmad-render-"))
self.addCleanup(shutil.rmtree, outer, True)
project = outer / name
project.mkdir(parents=True)
(project / "nested" / "cwd").mkdir(parents=True)
if shared_bmad is None:
bmad = project / "_bmad"
scripts = bmad / "scripts"
scripts.mkdir(parents=True)
for script in SHARED_SCRIPTS:
shutil.copy2(SCRIPTS_SRC / script, scripts / script)
(bmad / "custom").mkdir()
(bmad / "config.toml").write_text(
config if config is not None else _team_config(project),
encoding="utf-8",
)
else:
(project / "_bmad").symlink_to(shared_bmad)
bmad = shared_bmad
return SimpleNamespace(outer=outer, project=project, bmad=bmad)
def _skill(self, ws: SimpleNamespace, name: str) -> Path:
return _copy_skill(ws.outer / "skills" / name, name)
def _cli(
self,
project: Path,
skill: Path,
*,
cwd: Path | None = None,
args: tuple[str, ...] = (),
timeout: float | None = None,
) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[
sys.executable,
str(project / "_bmad" / "scripts" / "render_skill.py"),
"--project-root",
str(project),
"--skill",
str(skill),
*args,
],
cwd=cwd or project,
text=True,
capture_output=True,
check=False,
timeout=timeout,
)
def _entry(self, result: subprocess.CompletedProcess[str]) -> Path:
self.assertEqual(result.returncode, 0, msg=result.stdout + result.stderr)
lines = result.stdout.strip().split("\n")
self.assertEqual(len(lines), 1, msg=result.stdout)
self.assertTrue(lines[0].startswith(DISPATCH_PREFIX), msg=result.stdout)
output = Path(lines[0][len(DISPATCH_PREFIX) :])
self.assertTrue(output.is_absolute())
return output
def _assert_rendered(self, workflow: Path, project: Path, skill_name: str) -> Path:
snap = workflow.parent
self.assertEqual(workflow.name, "workflow.md")
self.assertIn(f"{os.sep}render{os.sep}{skill_name}{os.sep}", str(workflow))
self.assertFalse((snap / "SKILL.md").exists())
manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["project_root"], str(project.resolve()))
self.assertEqual(manifest["skill"], skill_name)
actual = _files(snap)
expected = [*manifest["outputs"], "manifest.json"]
self.assertEqual(sorted(actual), sorted(expected))
for name, digest in manifest["outputs"].items():
self.assertEqual(rs._hash_bytes(actual[name]), digest, name)
markdown = _markdown(snap)
self.assertIsNone(COMPILE_TOKEN.search(markdown), markdown)
self.assertNotIn("{skill-root}", markdown)
if skill_name not in ("bmad-retrospective", "bmad-qa-generate-e2e-tests"):
artifacts = (project.resolve() / "_bmad-output").as_posix()
self.assertIn(artifacts, markdown)
return snap
def _fixture_skill(self, ws: SimpleNamespace, defaults: str, workflow: str, **sources: str) -> Path:
skill = ws.outer / "skills" / "fixture"
skill.mkdir(parents=True)
(skill / "customize.toml").write_text(defaults, encoding="utf-8")
for name, content in {"workflow.md": workflow, **sources}.items():
(skill / name).write_text(content, encoding="utf-8")
return skill
def _assert_halt(self, result: subprocess.CompletedProcess[str], ws: SimpleNamespace) -> None:
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
self.assertEqual(len(result.stdout.splitlines()), 1, result.stdout)
self.assertNotIn("Traceback", result.stdout + result.stderr)
self.assertFalse((ws.bmad / "render").exists())
def test_invocation_precedence_flag_order_and_persistent_isolation(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n")
project_file = ws.bmad / "custom" / "fixture.toml"
user_file = ws.bmad / "custom" / "fixture.user.toml"
override_file = ws.project / "nested" / "cwd" / "invocation.toml"
project_file.write_text('[workflow]\nmessage = "project"\n', encoding="utf-8")
user_file.write_text('[workflow]\nmessage = "user"\n', encoding="utf-8")
override_file.write_text('[workflow]\nmessage = "file"\n', encoding="utf-8")
original = {
path: path.read_bytes() for path in (project_file, user_file, override_file, skill / "customize.toml")
}
baseline = rs.render(ws.project, skill)
self.assertEqual(baseline.read_text(), "user\n")
from_file = rs.render(ws.project, skill, overrides=override_file)
self.assertEqual(from_file.read_text(), "file\n")
expected = None
for args in (
(
"--set",
"workflow.message=command = wins",
"--overrides",
"invocation.toml",
),
(
"--overrides",
"invocation.toml",
"--set",
"workflow.message=command = wins",
),
):
entry = self._entry(self._cli(ws.project, skill, cwd=override_file.parent, args=args))
self.assertEqual(entry.read_text(), "command = wins\n")
if expected is not None:
self.assertEqual(entry, expected)
expected = entry
self.assertEqual(rs.render(ws.project, skill), baseline)
for path, content in original.items():
self.assertEqual(path.read_bytes(), content)
user_file.unlink()
self.assertEqual(rs.render(ws.project, skill).read_text(), "project\n")
project_file.unlink()
self.assertEqual(rs.render(ws.project, skill).read_text(), "shipped\n")
def test_equivalent_invocation_forms_merge_structures_and_reuse_rendered(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nfacts = ["base"]\n[workflow.details]\nlabel = "base"\nkept = "kept"\n'
'[[workflow.layers]]\nid = "a"\nname = "A"\ninstruction = "original"\n',
"{{ workflow.facts }}\n{{ workflow.details.label }} {{ workflow.details.kept }}\n{{ workflow.layers }}\n",
)
override = ws.project / "overrides.toml"
override.write_text(
'[workflow]\nfacts = ["added"]\ndetails = { label = "changed" }\n'
'layers = [{ id = "a", name = "Replaced", instruction = "replacement" }, '
'{ id = "b", instruction = "new" }]\n',
encoding="utf-8",
)
file_entry = rs.render(ws.project, skill, overrides=override)
command_entry = rs.render(
ws.project,
skill,
assignments=[
'workflow.facts=["added"]',
'workflow.details={ label = "changed" }',
'workflow.layers=[{ id = "a", name = "Replaced", instruction = "replacement" }, '
'{ id = "b", instruction = "new" }]',
],
)
self.assertEqual(file_entry, command_entry)
self.assertEqual(_files(file_entry.parent), _files(command_entry.parent))
content = command_entry.read_text()
self.assertIn("- base\n- added", content)
self.assertIn("changed kept", content)
self.assertIn("Replaced (`a`)", content)
self.assertIn("b (`b`)", content)
self.assertNotIn("original", content)
combined = rs.render(ws.project, skill, overrides=override, assignments=['workflow.facts=["command"]'])
self.assertIn("- base\n- added\n- command", combined.read_text())
def test_string_assignment_syntax_and_conflicting_paths_halt(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\n', "{{ workflow.message }}")
for assignment, expected in (
("workflow.message=words = more words", "words = more words"),
('workflow.message="line\\nnext"', "line\nnext"),
("workflow.message='literal = text'", "literal = text"),
("workflow.message=", ""),
):
with self.subTest(assignment=assignment):
# The nonempty shipped default deliberately rejects empty prose.
if not expected:
with self.assertRaisesRegex(rs.RenderError, "must not be empty"):
rs.render(ws.project, skill, assignments=[assignment])
else:
self.assertEqual(rs.render(ws.project, skill, assignments=[assignment]).read_text(), expected)
for assignments in (
["workflow.message=first", "workflow.message=second"],
['workflow={ message = "table" }', "workflow.message=child"],
["workflow.message=child", 'workflow={ message = "table" }'],
):
with self.subTest(assignments=assignments):
with self.assertRaisesRegex(rs.RenderError, "conflicts with earlier --set"):
rs.render(ws.project, skill, assignments=assignments)
def test_undeclared_persistent_key_halts_and_unread_declared_key_renders(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\nunread = "base"\n', "{{ workflow.message }}")
team = ws.bmad / "custom" / f"{skill.name}.toml"
user = ws.bmad / "custom" / f"{skill.name}.user.toml"
# A declared key this render never reads is still a valid persistent override.
user.write_text('[workflow]\nunread = "changed"\n', encoding="utf-8")
self.assertEqual(rs.render(ws.project, skill).read_text(), "base")
generations = set((ws.bmad / "render" / skill.name).rglob("manifest.json"))
for layer, content, expected in (
(
user,
'[workflow]\nmessage = "ok"\nmesage = "typo"\n',
r"user\.toml sets keys fixture does not declare: workflow\.mesage$",
),
(
team,
'[[workflow.review_layers]]\nid = "stale"\nname = "Stale"\ninstruction = "x"\n',
r"workflow\.review_layers$",
),
(team, "[workflow.message]\nnested = true\n", r"workflow\.message\.nested$"),
(team, 'message = "top level"\n', r"does not declare: message$"),
):
with self.subTest(layer=layer.name, content=content):
layer.write_text(content, encoding="utf-8")
with self.assertRaisesRegex(rs.RenderError, expected):
rs.render(ws.project, skill)
self.assertEqual(set((ws.bmad / "render" / skill.name).rglob("manifest.json")), generations)
layer.unlink()
@unittest.skipIf(hasattr(os, "geteuid") and os.geteuid() == 0, "root bypasses file permission bits")
def test_render_folder_that_refuses_new_files_halts(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n")
namespace = _namespace_dir(ws.project, "fixture")
namespace.mkdir(parents=True)
chmod(namespace, 0o555, deny="WD,AD")
self.addCleanup(chmod, namespace, 0o755)
# The timeout: on Windows, older Pythons' mkdtemp retried here some two
# billion times, and a hang must fail this test, not the job.
result = self._cli(ws.project, skill, timeout=60)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
self.assertIn("denied", result.stdout.lower())
self.assertNotIn("Traceback", result.stdout + result.stderr)
self.assertEqual(list(namespace.iterdir()), [])
def test_invalid_invocation_halts_before_publication(self):
invalid = (
("--set", "workflow.message"),
("--set", ".workflow.message=x"),
("--set", "workflow.unknown=x"),
("--set", 'workflow.message="unterminated'),
("--set", "workflow.count=words"),
# Declared but never reaches a token or condition in this render.
("--set", "workflow.count=7"),
# Consumed, so the consumer rejects the type.
("--set", "workflow.items=7"),
("--set", "workflow.items=[true]"),
("--set", "workflow.count=2\nextra=3"),
("--overrides", "missing.toml"),
("--overrides",),
("--set",),
)
for args in invalid:
with self.subTest(args=args):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nmessage = "base"\ncount = 1\nitems = ["base"]\n',
"{{ workflow.message }} {{ workflow.items }}\n",
)
self._assert_halt(self._cli(ws.project, skill, args=args), ws)
for content in ("[workflow", '[workflow]\nunknown="x"', "[workflow]\ncount=7", "[workflow]\nitems=7"):
with self.subTest(content=content):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\ncount = 1\nitems = ["base"]\n', "{{ workflow.items }}\n")
override = ws.project / "bad.toml"
override.write_text(content, encoding="utf-8")
self._assert_halt(self._cli(ws.project, skill, args=("--overrides", str(override))), ws)
def test_nested_conditions_omit_files_and_do_not_resolve_excluded_expressions(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nchoice = "left"\nenabled = true\n',
'{% if workflow.choice == "left" %}\nLeft {{ rendered("left.md") }}\n'
"{% if workflow.enabled %}\nEnabled\n{% else %}\n"
'{{ config.missing }} {{ workflow.missing }} {{ rendered("missing.md") }}\n{% endif %}\n'
'{% else %}\nRight {{ rendered("right.md") }}\n{% endif %}\n',
**{
"left.md": '{% if workflow.choice == "left" %}\nLeft detail\n{% endif %}\n',
"right.md": '\n{% if workflow.choice != "left" %}\nRight detail\n{% endif %}\n',
},
)
left = rs.render(ws.project, skill)
before = _files(left.parent)
# Standalone tag lines leave no blank lines behind.
self.assertEqual(left.read_text(), f"Left {(left.parent / 'left.md').as_posix()}\nEnabled\n")
self.assertEqual((left.parent / "left.md").read_text(), "Left detail\n")
self.assertFalse((left.parent / "right.md").exists())
right = rs.render(ws.project, skill, assignments=["workflow.choice=right"])
self.assertNotEqual(left, right)
self.assertEqual(right.read_text(), f"Right {(right.parent / 'right.md').as_posix()}\n")
self.assertFalse((right.parent / "left.md").exists())
self.assertTrue((right.parent / "right.md").exists())
self.assertEqual(before, _files(left.parent))
self.assertEqual(left, rs.render(ws.project, skill))
manifest = json.loads((left.parent / "manifest.json").read_text())
self.assertEqual(
manifest["inputs"]["resolved_values"],
{
"customization.workflow.choice": "left",
"customization.workflow.enabled": True,
},
)
self.assertIn("right.md", manifest["inputs"]["source_sha256"])
self.assertNotIn("right.md", manifest["outputs"])
(skill / "right.md").write_text((skill / "right.md").read_text().replace("Right detail", "Changed detail"))
self.assertNotEqual(left, rs.render(ws.project, skill))
def test_typed_scalar_condition_inputs_identify_generations_even_for_identical_output(self):
for default, condition, override in (
("true", "workflow.value == true", "false"),
("1", "workflow.value == 1", "2"),
("1.5", "workflow.value == 1.5", "2.5"),
('"one"', 'workflow.value == "one"', '"two"'),
("2026-09-07", 'workflow.value|string == "2026-09-07"', "2026-09-08"),
):
with self.subTest(default=default):
ws = self._workspace()
skill = self._fixture_skill(
ws,
f"[workflow]\nvalue = {default}\n",
f"{{% if {condition} %}}\nSame\n{{% else %}}\nSame\n{{% endif %}}\n",
)
before = rs.render(ws.project, skill)
after = rs.render(ws.project, skill, assignments=[f"workflow.value={override}"])
self.assertEqual(before.read_bytes(), after.read_bytes())
self.assertNotEqual(before, after)
self.assertEqual(after, rs.render(ws.project, skill, assignments=[f"workflow.value={override}"]))
def test_loops_iterate_list_values_and_direct_insertion_keeps_markdown_forms(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nfacts = ["one", "two"]\n[[workflow.layers]]\nid = "a"\nname = "A"\n'
'instruction = "Read {skill-root}/a.md"\n[[workflow.layers]]\nid = "b"\ninstruction = ""\n',
"{% for fact in workflow.facts %}\n* {{ fact }}\n{% endfor %}\n{{ workflow.facts }}\n"
"{% for layer in workflow.layers if layer.instruction %}\n{{ layer.id }}: {{ layer.instruction }}\n"
"{% endfor %}\n{{ workflow.layers }}\n",
)
entry = rs.render(ws.project, skill)
snap = entry.parent
self.assertEqual(
entry.read_text(),
f"* one\n* two\n- one\n- two\na: Read {snap.as_posix()}/a.md\n#### A (`a`)\n\nRead {snap.as_posix()}/a.md\n",
)
manifest = json.loads((snap / "manifest.json").read_text())
self.assertEqual(
manifest["inputs"]["resolved_values"],
{
"customization.workflow.facts": ["one", "two"],
"customization.workflow.layers": [
{"id": "a", "name": "A", "instruction": "Read {skill-root}/a.md"},
{"id": "b", "name": "b", "instruction": ""},
],
},
)
def test_template_errors_report_source_location_and_halt(self):
templates = (
'{% if workflow.value == "one" %}\ntext\n',
"{% else %}\n",
"{% endif %}\n",
"{% if %}\n{% endif %}\n",
"{{ workflow.value\n",
"{{ workflow.unknown }}\n",
"{{ nothing }}\n",
"{{ workflow }}\n",
"{{ config }}\n",
"{{ config.nothing }}\n",
"{{ config.core.nothing }}\n",
"{{ workflow.items.missing }}\n",
"{% for item in workflow.value %}{{ item }}{% endfor %}\n",
"{% for item in workflow.count %}{{ item }}{% endfor %}\n",
'{{ rendered("missing.md") }}\n',
"{{ rendered(workflow.items) }}\n",
'{{ halt("rejected") }}\n',
)
for template in templates:
with self.subTest(template=template):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nvalue = "one"\ncount = 1\nitems = []\n', f"ok\n{template}")
result = self._cli(ws.project, skill)
self._assert_halt(result, ws)
self.assertRegex(result.stdout, r"^HALT: workflow\.md:\d+: ")
ws = self._workspace()
skill = self._fixture_skill(
ws, '[workflow]\nvalue = "one"\n', "entry\n", **{"detail.md": "\n\n{{ nothing }}\n"}
)
with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:3: 'nothing' is undefined$"):
rs.render(ws.project, skill)
(skill / "detail.md").write_text("{{ workflow.missing }}\n", encoding="utf-8")
with self.assertRaisesRegex(
rs.RenderError, r"^detail\.md:1: missing customization parameter `workflow\.missing`$"
):
rs.render(ws.project, skill)
guard = '{% if workflow.value not in ("two", "three") %}{{ halt("value must be two or three, not " ~ workflow.value) }}{% endif %}\n'
(skill / "detail.md").write_text(guard + "kept\n", encoding="utf-8")
with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:1: value must be two or three, not one$"):
rs.render(ws.project, skill)
self.assertEqual(
_files(rs.render(ws.project, skill, assignments=["workflow.value=two"]).parent)["detail.md"], b"kept\n"
)
def test_excluded_entry_and_surviving_reference_to_excluded_file_halt(self):
for workflow in (
"{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n",
'Read {{ rendered("detail.md") }}\n',
):
with self.subTest(workflow=workflow):
ws = self._workspace()
skill = self._fixture_skill(
ws,
"[workflow]\nenabled = true\n",
workflow,
**{"detail.md": "{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n"},
)
self._assert_halt(self._cli(ws.project, skill), ws)
def test_raw_blocks_keep_agent_placeholders_verbatim(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
"[workflow]\n",
"epic: {% raw %}{{epic_number}}{% endraw +%}\nnext {% raw %}{{prev}}{% endraw %} {{ '{%' }} done\n",
)
self.assertEqual(rs.render(ws.project, skill).read_text(), "epic: {{epic_number}}\nnext {{prev}} {% done\n")
def test_invocation_prose_keeps_template_syntax_opaque(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = ""\n', "{{ workflow.message }}\n")
literal = "{% if workflow.missing %}\n{{ workflow.missing }} {{ config.missing }} {# note #}\n{% endif %}"
literal += '\n{{ rendered("missing.md") }} {skill-root}/detail.md'
entry = rs.render(ws.project, skill, assignments=[f"workflow.message={literal}"])
self.assertEqual(entry.read_text(), literal.replace("{skill-root}", entry.parent.as_posix()) + "\n")
def test_jinja2_version_is_part_of_the_generation_identity(self):
ws = self._workspace()
skill = self._fixture_skill(ws, "[workflow]\n", "stable\n")
original = rs.render(ws.project, skill)
manifest = json.loads((original.parent / "manifest.json").read_text())
self.assertEqual(manifest["inputs"]["jinja2_version"], rs.jinja2.__version__)
with mock.patch.object(rs.jinja2, "__version__", "0.0.0"):
changed = rs.render(ws.project, skill)
self.assertNotEqual(changed, original)
self.assertEqual(changed.read_bytes(), original.read_bytes())
self.assertTrue(original.exists())
def test_shipped_skills_publish_root_bound_snapshots(self):
for name in SHIPPED_SKILLS:
with self.subTest(name):
ws = self._workspace()
skill = self._skill(ws, name)
workflow = rs.render(ws.project, skill, assignments=["workflow.review=thorough"])
snap = self._assert_rendered(workflow, ws.project, name)
self.assertIn("{plan_file}", _markdown(snap))
hunter = snap / "review-prompts" / "edge-case-hunter.md"
self.assertTrue(hunter.is_file())
self.assertIn(hunter.as_posix(), _markdown(snap))
def test_rendered_skills_publish_snapshots_without_skill_root(self):
for name in RENDERED_SKILLS:
with self.subTest(name):
ws = self._workspace()
skill = self._skill(ws, name)
workflow = rs.render(ws.project, skill)
self._assert_rendered(workflow, ws.project, name)
def test_build_skills_render_the_active_initiative_branch(self):
for name in ("bmad-build", "bmad-build-auto"):
with self.subTest(name):
ws = self._workspace()
(ws.bmad / "custom" / "config.user.toml").write_text(
'[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8"
)
skill = self._skill(ws, name)
snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, name)
markdown = _markdown(snap)
folder = (ws.project.resolve() / "_bmad-output" / "initiative-checkout").as_posix()
self.assertIn(f"{folder}/plan-{{slug}}.md", markdown)
self.assertNotIn("No initiative is active", markdown)
def test_build_skills_render_each_pinned_route(self):
for name in ("bmad-build", "bmad-build-auto"):
for route in ("oneshot", "full"):
with self.subTest(name=name, route=route):
ws = self._workspace()
skill = self._skill(ws, name)
workflow = rs.render(ws.project, skill, assignments=[f"workflow.route={route}"])
self._assert_rendered(workflow, ws.project, name)
def test_build_skills_default_to_quick_review(self):
for name in ("bmad-build", "bmad-build-auto"):
with self.subTest(name):
ws = self._workspace()
skill = self._skill(ws, name)
snap = rs.render(ws.project, skill).parent
markdown = _markdown(snap)
self.assertIn("Write `review: 'quick'` and `review_source: 'pinned'`", markdown)
self.assertIn("Quick (`quick`)", markdown)
self.assertNotIn((snap / "review-prompts" / "edge-case-hunter.md").as_posix(), markdown)
def test_skill_root_binds_bundled_scripts_to_the_installed_skill(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-retrospective")
snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, "bmad-retrospective")
markdown = _markdown(snap)
self.assertIn((skill / "scripts" / "git_evidence.py").as_posix(), markdown)
manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["inputs"]["skill_root"], str(skill.resolve()))
elsewhere = _copy_skill(ws.outer / "elsewhere" / "bmad-retrospective", "bmad-retrospective")
other = rs.render(ws.project, elsewhere)
self.assertNotEqual(other.parent, snap)
self.assertIn((elsewhere / "scripts" / "git_evidence.py").as_posix(), _markdown(other.parent))
def test_cli_from_nested_cwd_dispatches_one_absolute_workflow(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
workflow = self._entry(self._cli(ws.project, skill, cwd=ws.project / "nested" / "cwd"))
self._assert_rendered(workflow, ws.project, "bmad-build")
self.assertFalse((ws.bmad / "scripts" / "__pycache__").exists())
self.assertFalse((skill / "__pycache__").exists())
def test_identical_input_and_unreferenced_config_reuse_bytes(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
first = rs.render(ws.project, skill)
first_files = _files(first.parent)
self.assertEqual(rs.render(ws.project, skill), first)
with (ws.bmad / "config.toml").open("a", encoding="utf-8") as handle:
handle.write('\nunreferenced_value = "ignored"\n')
self.assertEqual(rs.render(ws.project, skill), first)
current = _files(first.parent)
for name, content in first_files.items():
self.assertEqual(current[name], content, name)
def test_referenced_config_and_source_changes_publish_new_generations(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build-auto")
before = rs.render(ws.project, skill)
before_files = _files(before.parent)
(ws.bmad / "custom" / "config.toml").write_text(
'[core]\noutput_folder = "{project-root}/impl-v2"\n',
encoding="utf-8",
)
after_config = rs.render(ws.project, skill)
self.assertNotEqual(after_config, before)
self.assertIn("/impl-v2/", after_config.read_text(encoding="utf-8"))
self.assertTrue(before.exists())
(skill / "plan-template.md").write_text(
(skill / "plan-template.md").read_text(encoding="utf-8") + "\n<!-- effective change -->\n",
encoding="utf-8",
)
after_source = rs.render(ws.project, skill)
self.assertNotEqual(after_source, after_config)
current = _files(before.parent)
for name, content in before_files.items():
self.assertEqual(current[name], content, name)
def test_initiative_folder_follows_the_active_initiative_where_a_template_reaches_it(self):
ws = self._workspace()
skill = self._fixture_skill(ws, "[workflow]\n", "{{ initiative_folder }}\n")
user_config = ws.bmad / "custom" / "config.user.toml"
output_folder = (ws.project.resolve() / "_bmad-output").as_posix()
unset = rs.render(ws.project, skill)
self.assertEqual(unset.read_text(encoding="utf-8"), f"{output_folder}\n")
user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8")
active = rs.render(ws.project, skill)
self.assertNotEqual(active, unset)
self.assertEqual(active.read_text(encoding="utf-8"), f"{output_folder}/initiative-checkout\n")
for value, message in (("42", "must be a string"), ('""', "must not be empty")):
with self.subTest(value=value):
user_config.write_text(f"[core]\nactive_initiative = {value}\n", encoding="utf-8")
with self.assertRaisesRegex(rs.RenderError, f"config.core.active_initiative {message}"):
rs.render(ws.project, skill)
(skill / "workflow.md").write_text("plain\n", encoding="utf-8")
user_config.unlink()
unreferenced = rs.render(ws.project, skill)
user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8")
self.assertEqual(rs.render(ws.project, skill), unreferenced)
def test_shared_runtime_keeps_distinct_root_bound_snapshots(self):
first = self._workspace()
skill = self._skill(first, "bmad-build")
second = self._workspace(name="other", shared_bmad=first.bmad)
one = rs.render(first.project, skill)
two = rs.render(second.project, skill)
self.assertNotEqual(one, two)
self.assertIn(first.project.resolve().as_posix(), one.read_text(encoding="utf-8"))
self.assertIn(second.project.resolve().as_posix(), two.read_text(encoding="utf-8"))
def test_concurrent_cli_renderers_reuse_one_complete_generation(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
with ThreadPoolExecutor(max_workers=2) as pool:
results = list(pool.map(lambda _: self._cli(ws.project, skill), range(2)))
entries = [self._entry(result) for result in results]
self.assertEqual(entries[0], entries[1])
self.assertTrue((entries[0].parent / "manifest.json").is_file())
def test_malformed_config_and_customization_halt_without_traceback(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
(ws.bmad / "custom" / "config.toml").write_text("[core\nbad", encoding="utf-8")
result = self._cli(ws.project, skill)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
self.assertNotIn(DISPATCH_PREFIX, result.stdout)
self.assertNotIn("Traceback", result.stdout + result.stderr)
(ws.bmad / "custom" / "config.toml").unlink()
(ws.bmad / "custom" / f"{skill.name}.toml").write_text("[workflow\nbad", encoding="utf-8")
result = self._cli(ws.project, skill)
self.assertNotEqual(result.returncode, 0)
self.assertIn("failed to parse", result.stdout)
def test_missing_wrong_type_and_non_string_layer_id_halt(self):
template = _team_config(Path("project"))
missing = template.replace(
'output_folder = "{project-root}/_bmad-output"\n',
"",
)
ws = self._workspace(config=missing)
skill = self._skill(ws, "bmad-build")
result = self._cli(ws.project, skill)
self.assertIn("missing config value", result.stdout)
wrong = template.replace(
'output_folder = "{project-root}/_bmad-output"',
"output_folder = 42",
)
ws = self._workspace(config=wrong)
skill = self._skill(ws, "bmad-build")
result = self._cli(ws.project, skill)
self.assertIn("must be a string", result.stdout)
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(
'[[workflow.thorough_lenses]]\nid = 42\nname = "bad"\ninstruction = "bad"\n',
encoding="utf-8",
)
result = self._cli(ws.project, skill)
self.assertIn("identifier `id` must be a string", result.stdout)
def test_customization_prose_is_not_rescanned_as_source_tokens(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
literal = '{{ rendered("step-04-review.md") }}'
compile_literal = "{{ workflow.implementation_handoff }}"
(ws.bmad / "custom" / f"{skill.name}.user.toml").write_text(
f"[workflow]\non_complete = 'Preserve {literal} and {compile_literal} as prose'\n",
encoding="utf-8",
)
markdown = _markdown(rs.render(ws.project, skill).parent)
self.assertIn(literal, markdown)
self.assertIn(compile_literal, markdown)
def test_review_layer_override_guard_and_empty_layer_halt(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(
"\n".join(
[
"[workflow]",
'review = "thorough"',
"[[workflow.thorough_lenses]]",
'id = "blind-hunter"',
'name = "Replacement"',
'instruction = "Run replacement review."',
'when = "the replacement condition holds"',
"",
]
),
encoding="utf-8",
)
review = (rs.render(ws.project, skill).parent / "step-04-review.md").read_text(encoding="utf-8")
self.assertIn("Replacement (`blind-hunter`)", review)
self.assertIn("Run only when: the replacement condition holds", review)
self.assertIn("Run replacement review.", review)
defaults = tomllib.loads((skill / "customize.toml").read_text(encoding="utf-8"))
disabled = '[workflow]\nreview = "thorough"\n' + "\n".join(
f'[[workflow.thorough_lenses]]\nid = "{layer["id"]}"\nname = "disabled"\ninstruction = ""\n'
for layer in defaults["workflow"]["thorough_lenses"]
)
(ws.bmad / "custom" / f"{skill.name}.toml").write_text(disabled, encoding="utf-8")
review = (rs.render(ws.project, skill).parent / "step-04-review.md").read_text(encoding="utf-8")
self.assertIn("No active review layers. HALT", review)
def test_non_empty_open_plan_override_reaches_both_terminal_routes(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
(ws.bmad / "custom" / f"{skill.name}.user.toml").write_text(
'[workflow]\nopen_plan = "OPEN-PLAN-SENTINEL {project-root} {plan_file}"\n',
encoding="utf-8",
)
snap = rs.render(ws.project, skill).parent
for name in ("step-05-present.md", "step-oneshot.md"):
rendered = (snap / name).read_text(encoding="utf-8")
self.assertIn("OPEN-PLAN-SENTINEL {project-root} {plan_file}", rendered)
def test_installed_renderer_identity_change_publishes_a_new_generation(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
original = self._entry(self._cli(ws.project, skill))
with (ws.bmad / "scripts" / "render_skill.py").open("a", encoding="utf-8") as handle:
handle.write("\n# renderer identity change\n")
changed = self._entry(self._cli(ws.project, skill))
self.assertNotEqual(changed, original)
self.assertTrue(original.exists())
def test_convention_only_skill_renders_without_customization(self):
ws = self._workspace()
skill = ws.outer / "skills" / "plain-workflow"
skill.mkdir(parents=True)
(skill / "workflow.md").write_text('Read `{{ rendered("step.md") }}`.\n', encoding="utf-8")
(skill / "step.md").write_text("No rendered values required.\n", encoding="utf-8")
workflow = rs.render(ws.project, skill)
self.assertIn(f"{os.sep}render{os.sep}plain-workflow{os.sep}", str(workflow))
self.assertTrue((workflow.parent / "step.md").is_file())
self.assertIn((workflow.parent / "step.md").as_posix(), workflow.read_text(encoding="utf-8"))
def test_ambiguous_shorthand_and_source_symlink_escape_halt(self):
config = _team_config(Path("project")) + '\n[modules.bmm]\noutput_folder = "{project-root}/dup"\n'
ws = self._workspace(config=config)
skill = self._skill(ws, "bmad-build")
result = self._cli(ws.project, skill)
self.assertIn("ambiguous config value", result.stdout)
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
outside = ws.outer / "outside.md"
outside.write_text("outside\n", encoding="utf-8")
(skill / "workflow.md").unlink()
(skill / "workflow.md").symlink_to(outside)
result = self._cli(ws.project, skill)
self.assertIn("escapes skill directory", result.stdout)
def test_long_project_basename_is_bounded_in_the_snapshot_namespace(self):
ws = self._workspace(name="project-" + ("x" * 220))
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
self.assertLessEqual(len(workflow.parent.parent.name), 93)
def test_snapshot_paths_stay_opaque_when_the_project_name_looks_like_tokens(self):
ws = self._workspace(name="{{ workflow.on_complete }}-{{ config.output_folder }}")
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
text = workflow.read_text(encoding="utf-8")
match = re.search(r"`([^`]*step-01-clarify-and-route\.md)`", text)
self.assertIsNotNone(match, text)
self.assertTrue(match.group(1).startswith(ws.project.resolve().as_posix()))
self.assertTrue(Path(match.group(1)).is_file())
def test_publication_failure_does_not_dispatch_or_alter_another_root(self):
stable = self._workspace()
skill = self._skill(stable, "bmad-build")
original = rs.render(stable.project, skill)
original_files = _files(original.parent)
broken = self._workspace(name="broken", shared_bmad=stable.bmad)
namespace = _namespace_dir(broken.project, skill.name)
namespace.parent.mkdir(parents=True, exist_ok=True)
namespace.write_text("not a directory\n", encoding="utf-8")
result = self._cli(broken.project, skill)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(result.stdout.startswith("HALT:"), result.stdout)
self.assertNotIn(DISPATCH_PREFIX, result.stdout)
current = _files(original.parent)
for name, content in original_files.items():
self.assertEqual(current[name], content, name)
def test_corrupt_existing_destination_is_never_overwritten(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
workflow.write_text(workflow.read_text(encoding="utf-8") + "corrupt", encoding="utf-8")
result = self._cli(ws.project, skill)
self.assertNotEqual(result.returncode, 0)
self.assertIn("hash mismatch", result.stdout)
self.assertTrue(workflow.read_text(encoding="utf-8").endswith("corrupt"))
def test_stray_file_in_generation_is_ignored_and_missing_output_is_named(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
stray = workflow.parent / "Thumbs.db"
stray.write_bytes(b"explorer")
result = self._cli(ws.project, skill)
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
self.assertEqual(result.stdout, f"{DISPATCH_PREFIX}{workflow}\n")
self.assertEqual(stray.read_bytes(), b"explorer")
workflow.unlink()
result = self._cli(ws.project, skill)
self.assertEqual(result.returncode, 1, result.stdout + result.stderr)
self.assertEqual(len(result.stdout.splitlines()), 1, result.stdout)
self.assertNotIn("Traceback", result.stdout + result.stderr)
self.assertIn(f"missing rendered files: workflow.md in {workflow.parent}", result.stdout)
self.assertIn("deleting that folder is safe", result.stdout)
self.assertFalse(workflow.exists())
def test_skill_md_command_dispatches_for_every_rendered_skill(self):
for name in RENDERED_SKILLS:
with self.subTest(name):
ws = self._workspace()
skill = self._skill(ws, name)
text = (skill / "SKILL.md").read_text(encoding="utf-8")
fenced = re.search(r"```bash\n(.*?)```", text, re.S)
self.assertIsNotNone(fenced, f"{name}: SKILL.md ships no bash command")
command = (
fenced.group(1)
.strip()
.replace("{project-root}", str(ws.project))
.replace("{skill-root}", str(skill))
)
self.assertNotIn("{", command)
dispatched = self._entry(
subprocess.run(
command,
cwd=ws.project / "nested" / "cwd",
shell=True,
text=True,
capture_output=True,
check=False,
)
)
self.assertEqual(dispatched.name, "workflow.md")
self.assertTrue(dispatched.is_file())
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,109 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_config.py"
class ResolveConfigCliTests(unittest.TestCase):
def test_missing_tomllib_exits_with_actionable_version_error(self):
with tempfile.TemporaryDirectory() as temp_dir:
scripts = Path(temp_dir)
shutil.copy2(SCRIPT, scripts / SCRIPT.name)
shutil.copy2(SCRIPT.parent / "config_utils.py", scripts / "config_utils.py")
(scripts / "tomllib.py").write_text(
'raise ModuleNotFoundError("No module named tomllib", name="tomllib")\n',
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, str(scripts / SCRIPT.name), "--help"],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 3)
self.assertEqual(
result.stderr,
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n",
)
self.assertNotIn("Traceback", result.stderr)
def test_full_and_repeated_key_output_follow_layer_precedence(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
custom = root / "_bmad" / "custom"
custom.mkdir(parents=True)
(root / "_bmad" / "config.toml").write_text('[core]\nname = "base"\nkeep = "yes"\n', encoding="utf-8")
(root / "_bmad" / "config.user.toml").write_text(
'[core]\nname = "base-user"\nstray = "ignored"\n', encoding="utf-8"
)
(custom / "config.toml").write_text('[core]\nname = "team"\n', encoding="utf-8")
(custom / "config.user.toml").write_text('[core]\nname = "user"\n', encoding="utf-8")
full = self._run(root)
self.assertEqual(full.returncode, 0, msg=full.stderr)
# _bmad/config.user.toml is old-installer debris, not a layer:
# its `stray` key never reaches the merge.
self.assertEqual(json.loads(full.stdout)["core"], {"name": "user", "keep": "yes"})
keyed = self._run(root, "--key", "core.name", "--key", "missing")
self.assertEqual(keyed.returncode, 0, msg=keyed.stderr)
self.assertEqual(json.loads(keyed.stdout), {"core.name": "user"})
def test_malformed_present_layer_fails(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
custom = root / "_bmad" / "custom"
custom.mkdir(parents=True)
(root / "_bmad" / "config.toml").write_text("[core]\nvalid = true\n", encoding="utf-8")
(custom / "config.toml").write_text("[broken\n", encoding="utf-8")
result = self._run(root)
self.assertNotEqual(result.returncode, 0)
self.assertIn("failed to parse", result.stderr)
def test_writes_emoji_json_when_stdout_encoding_is_cp1252(self):
with tempfile.TemporaryDirectory() as temp_dir:
root = Path(temp_dir)
(root / "_bmad").mkdir(parents=True)
(root / "_bmad" / "config.toml").write_text(
'[agents]\nname = "Analyst"\nicon = "📊"\n',
encoding="utf-8",
)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "cp1252"
result = subprocess.run(
[sys.executable, str(SCRIPT), "--project-root", str(root)],
capture_output=True,
env=env,
check=False,
)
stderr = result.stderr.decode("utf-8", errors="replace")
self.assertEqual(result.returncode, 0, msg=stderr)
output = result.stdout.decode("utf-8")
self.assertIn("📊", output)
resolved = json.loads(output)
self.assertEqual(resolved["agents"]["icon"], "📊")
@staticmethod
def _run(root: Path, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(SCRIPT), "--project-root", str(root), *args],
text=True,
capture_output=True,
check=False,
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,175 @@
import json
import os
import shutil
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "resolve_customization.py"
class ResolveCustomizationStdoutTests(unittest.TestCase):
def test_missing_tomllib_exits_with_actionable_version_error(self):
with tempfile.TemporaryDirectory() as temp_dir:
scripts = Path(temp_dir)
shutil.copy2(SCRIPT, scripts / SCRIPT.name)
shutil.copy2(SCRIPT.parent / "config_utils.py", scripts / "config_utils.py")
(scripts / "tomllib.py").write_text(
'raise ModuleNotFoundError("No module named tomllib", name="tomllib")\n',
encoding="utf-8",
)
result = subprocess.run(
[sys.executable, str(scripts / SCRIPT.name), "--help"],
text=True,
capture_output=True,
check=False,
)
self.assertEqual(result.returncode, 3)
self.assertEqual(
result.stderr,
"error: Python 3.11+ is required (stdlib `tomllib` not found).\n",
)
self.assertNotIn("Traceback", result.stderr)
def test_writes_emoji_json_when_stdout_encoding_is_cp1252(self):
with tempfile.TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "emoji-agent"
skill_dir.mkdir()
(skill_dir / "customize.toml").write_text(
'[agent]\nname = "Emoji Agent"\nicon = "🧭"\n',
encoding="utf-8",
)
env = os.environ.copy()
env["PYTHONIOENCODING"] = "cp1252"
result = subprocess.run(
[
sys.executable,
str(SCRIPT),
"--skill",
str(skill_dir),
"--key",
"agent",
],
capture_output=True,
cwd=temp_dir,
env=env,
check=False,
)
stderr = result.stderr.decode("utf-8", errors="replace")
self.assertEqual(result.returncode, 0, msg=stderr)
output = result.stdout.decode("utf-8")
self.assertIn("🧭", output)
resolved = json.loads(output)
self.assertEqual(resolved["agent"]["icon"], "🧭")
def write(path: Path, body: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(body, encoding="utf-8")
def facts(*entries: str) -> str:
listed = ", ".join(f'"{entry}"' for entry in entries)
return f"[workflow]\npersistent_facts = [{listed}]\n"
def resolve(skill_dir: Path, cwd: Path, *extra: str):
return subprocess.run(
[sys.executable, str(SCRIPT), "--skill", str(skill_dir), "--key", "workflow", *extra],
text=True,
capture_output=True,
cwd=str(cwd),
check=False,
)
class ProjectRootResolutionTests(unittest.TestCase):
"""Regression cover for #2796 — the project is where the user works, not
where the skill is installed."""
def test_home_installed_skill_reads_project_override_not_home_bmad(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir) / "home"
project = Path(temp_dir) / "project"
skill = home / ".claude" / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
(home / "_bmad" / "custom").mkdir(parents=True)
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
result = resolve(skill, project)
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default", "team override"])
def test_project_installed_skill_still_resolves(self):
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
skill = project / ".claude" / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
result = resolve(skill, project)
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default", "team override"])
def test_walk_prefers_bmad_over_a_nearer_git_directory(self):
with tempfile.TemporaryDirectory() as temp_dir:
project = Path(temp_dir) / "project"
submodule = project / "vendor" / "sub"
skill = Path(temp_dir) / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
write(project / "_bmad" / "custom" / "demo-skill.toml", facts("team override"))
(submodule / ".git").mkdir(parents=True)
result = resolve(skill, submodule)
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default", "team override"])
def test_notes_when_a_rejected_root_holds_the_only_override(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir) / "home"
project = Path(temp_dir) / "project"
skill = home / ".claude" / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
write(home / "_bmad" / "custom" / "demo-skill.toml", facts("home override"))
(project / "_bmad" / "custom").mkdir(parents=True)
result = resolve(skill, project)
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default"])
self.assertIn("demo-skill", result.stderr)
self.assertIn("--project-root", result.stderr)
def test_explicit_project_root_wins_and_stays_quiet(self):
with tempfile.TemporaryDirectory() as temp_dir:
home = Path(temp_dir) / "home"
project = Path(temp_dir) / "project"
skill = home / ".claude" / "skills" / "demo-skill"
write(skill / "customize.toml", facts("shipped default"))
write(home / "_bmad" / "custom" / "demo-skill.toml", facts("home override"))
(project / "_bmad" / "custom").mkdir(parents=True)
result = resolve(skill, project, "--project-root", str(home))
self.assertEqual(result.returncode, 0, msg=result.stderr)
resolved = json.loads(result.stdout)["workflow"]["persistent_facts"]
self.assertEqual(resolved, ["shipped default", "home override"])
self.assertEqual(result.stderr, "")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,226 @@
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import roster # noqa: E402
SOURCE = "github:acme/tools/skills"
ROSTER = """
[[members]]
code = "demo-agent-ann"
skill = "demo-agent-ann"
name = "Ann"
icon = "A"
title = "Analyst"
persona = "Asks for evidence."
[[members]]
code = "demo-agent-bob"
skill = "demo-agent-bob"
name = "Bob"
persona = "Builds it."
[[members]]
code = "guest"
name = "Guest"
persona = "Only in groups."
[[groups]]
id = "demo-room"
name = "Demo Room"
members = ["demo-agent-ann", "demo-agent-bob", "guest"]
"""
def write_skill(root: Path, name: str, *, bmod: str = "bmod-demo") -> Path:
folder = root / name
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{bmod}"\nsource = "{SOURCE}"\n', encoding="utf-8")
return folder
def write_module(
root: Path,
skills: tuple[str, ...] = (),
*,
code: str = "demo",
folder: str | None = None,
listed: tuple[str, ...] | None = None,
roster_text: str | None = ROSTER,
) -> Path:
"""Write a module record and the member skill folders in `skills`; `listed` is what the record names."""
record = root / (folder or f"bmod-{code}")
record.mkdir(parents=True)
names = ", ".join(f'"{name}"' for name in (skills if listed is None else listed))
lines = ["[bmod]", f'code = "{code}"', 'version = "1.0.0"', f'update_source = "{SOURCE}"', f"skills = [{names}]"]
if roster_text is not None:
(record / "roster.toml").write_text(roster_text, encoding="utf-8")
(record / "bmod.toml").write_text("\n".join(lines) + "\n", encoding="utf-8")
for name in skills:
write_skill(root, name, bmod=record.name)
return record
class RosterTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.project = Path(self.temp.name)
self.skills = self.project / ".claude" / "skills"
def test_only_members_whose_skill_is_installed_are_agents(self):
write_module(self.skills, ("demo-agent-ann",), listed=("demo-agent-ann", "demo-agent-bob"))
(self.skills / "demo-agent-ann" / "customize.toml").write_text('[agent]\nname = "Ann"\n', encoding="utf-8")
report = roster.collect([self.skills])
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
self.assertEqual(set(report["members"]), {"demo-agent-ann", "demo-agent-bob", "guest"})
self.assertNotIn("installed", report["members"]["guest"])
def test_an_absent_agent_keeps_its_persona_and_names_its_install_command(self):
write_module(self.skills, ("demo-workflow",))
bob = roster.collect([self.skills])["members"]["demo-agent-bob"]
self.assertFalse(bob["installed"])
self.assertEqual(bob["persona"], "Builds it.")
self.assertEqual(bob["install"], "npx skills add acme/tools --skill demo-agent-bob")
def test_an_installed_agent_answers_to_its_customized_name(self):
write_module(self.skills, ("demo-agent-ann",))
(self.skills / "demo-agent-ann" / "customize.toml").write_text(
'[agent]\nname = "Ann"\ntitle = "Analyst"\n', encoding="utf-8"
)
custom = self.project / "_bmad" / "custom"
custom.mkdir(parents=True)
(custom / "demo-agent-ann.toml").write_text('[agent]\nname = "Annika"\n', encoding="utf-8")
agent = roster.collect([self.skills], self.project)["agents"]["demo-agent-ann"]
self.assertEqual((agent["name"], agent["title"], agent["persona"]), ("Annika", "Analyst", "Asks for evidence."))
def test_a_roster_is_read_from_the_record_and_lists_the_installed_member_skills(self):
write_module(self.skills, ("demo-one", "demo-two"), listed=("demo-one", "demo-two", "demo-absent"))
report = roster.collect([self.skills])
self.assertEqual(
report["rosters"], [{"module": "demo", "path": "roster.toml", "skills": ["demo-one", "demo-two"]}]
)
self.assertEqual([group["id"] for group in report["groups"]], ["demo-room"])
self.assertEqual(report["problems"], [])
def test_a_module_with_no_skills_still_offers_its_roster(self):
write_module(self.skills)
report = roster.collect([self.skills])
self.assertEqual(report["rosters"], [{"module": "demo", "path": "roster.toml", "skills": []}])
self.assertEqual(set(report["members"]), {"demo-agent-ann", "demo-agent-bob", "guest"})
self.assertEqual((report["agents"], report["problems"]), ({}, []))
def test_a_single_skill_module_is_its_own_member(self):
folder = self.skills / "demo-agent-ann"
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text(
f'[bmod]\ncode = "solo"\nversion = "1.0.0"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
encoding="utf-8",
)
(folder / "roster.toml").write_text(ROSTER, encoding="utf-8")
report = roster.collect([self.skills])
self.assertEqual(report["rosters"], [{"module": "solo", "path": "roster.toml", "skills": ["demo-agent-ann"]}])
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
self.assertEqual(report["problems"], [])
def test_a_record_is_found_whatever_its_folder_is_called(self):
write_module(self.skills, ("demo-agent-ann",), folder="demo-record")
report = roster.collect([self.skills])
self.assertEqual(list(report["agents"]), ["demo-agent-ann"])
self.assertEqual(report["problems"], [])
def test_a_second_record_for_a_code_is_a_problem_and_the_first_wins(self):
write_module(self.skills, ("demo-one",), folder="a-record")
write_module(self.skills, ("demo-two",), folder="b-record", roster_text='[[members]]\ncode = "late"\n')
report = roster.collect([self.skills])
self.assertNotIn("late", report["members"])
self.assertEqual([found["skills"] for found in report["rosters"]], [["demo-one"]])
self.assertEqual(
[(problem["kind"], problem["skill"]) for problem in report["problems"]], [("module", "b-record")]
)
def test_a_member_skill_whose_record_is_absent_is_a_problem(self):
write_skill(self.skills, "demo-agent-ann")
report = roster.collect([self.skills])
self.assertEqual((report["agents"], report["members"], report["rosters"]), ({}, {}, []))
(problem,) = report["problems"]
self.assertEqual(
(problem["kind"], problem["bmod"], problem["skills"]), ("module", "bmod-demo", ["demo-agent-ann"])
)
self.assertEqual(problem["install"], "npx skills add acme/tools --skill bmod-demo")
def test_a_second_module_cannot_redefine_a_member_or_group(self):
write_module(self.skills, ("demo-one",))
write_module(self.skills, ("other-one",), code="other")
report = roster.collect([self.skills])
self.assertEqual(report["members"]["guest"]["module"], "demo")
self.assertEqual(
sorted(problem["kind"] for problem in report["problems"]), ["group", "member", "member", "member"]
)
def test_central_config_agents_are_laid_over_the_scan(self):
write_module(self.skills, ("demo-workflow",))
(self.project / "_bmad").mkdir()
(self.project / "_bmad" / "config.toml").write_text(
'[agents.my-agent]\nname = "Mine"\ndescription = "From before rosters."\n', encoding="utf-8"
)
agent = roster.collect([self.skills], self.project)["agents"]["my-agent"]
self.assertEqual((agent["name"], agent["persona"], agent["source"]), ("Mine", "From before rosters.", "config"))
def test_a_recorded_agent_whose_skill_was_removed_stays_out_of_the_default_room(self):
write_module(self.skills, ("demo-agent-ann",))
(self.project / "_bmad").mkdir()
(self.project / "_bmad" / "config.toml").write_text(
'[agents.demo-agent-ann]\nteam = "crew"\n\n[agents.demo-agent-bob]\nname = "Bob"\n', encoding="utf-8"
)
agents = roster.collect([self.skills], self.project)["agents"]
self.assertEqual(sorted(agents), ["demo-agent-ann"])
self.assertEqual(agents["demo-agent-ann"]["team"], "crew")
def test_config_does_not_replace_a_roster_agents_name_or_module(self):
write_module(self.skills, ("demo-agent-ann",))
(self.project / "_bmad").mkdir()
(self.project / "_bmad" / "config.toml").write_text(
'[agents.demo-agent-ann]\nmodule = "old"\nname = "Recorded"\nteam = "crew"\n', encoding="utf-8"
)
agent = roster.collect([self.skills], self.project)["agents"]["demo-agent-ann"]
self.assertEqual((agent["module"], agent["team"]), ("demo", "crew"))
self.assertNotEqual(agent["name"], "Recorded")
def test_a_roster_key_in_the_record_is_ignored(self):
record = write_module(self.skills, ("demo-one",), roster_text=None)
text = (record / "bmod.toml").read_text(encoding="utf-8")
(record / "bmod.toml").write_text(text + 'roster = ["other.toml"]\n', encoding="utf-8")
(record / "other.toml").write_text(ROSTER, encoding="utf-8")
report = roster.collect([self.skills])
self.assertEqual((report["members"], report["rosters"], report["problems"]), ({}, [], []))
def test_a_roster_that_links_outside_the_record_folder_is_refused(self):
record = write_module(self.skills, ("demo-one",))
(self.skills / "demo-one" / "elsewhere.toml").write_text(ROSTER, encoding="utf-8")
(record / "roster.toml").unlink()
try:
(record / "roster.toml").symlink_to(self.skills / "demo-one" / "elsewhere.toml")
except OSError:
self.skipTest("symlinks are not available")
report = roster.collect([self.skills])
self.assertEqual(report["members"], {})
self.assertEqual([problem["kind"] for problem in report["problems"]], ["roster"])
def test_members_or_groups_that_are_not_lists_are_problems(self):
write_module(self.skills, ("demo-one",), roster_text='members = "ann"\n\n[groups]\nid = "room"\n')
report = roster.collect([self.skills])
self.assertEqual((report["members"], report["groups"]), ({}, []))
self.assertEqual([problem["kind"] for problem in report["problems"]], ["roster", "roster"])
def test_a_module_without_a_roster_offers_nothing(self):
write_module(self.skills, ("demo-one",), roster_text=None)
report = roster.collect([self.skills])
self.assertEqual((report["agents"], report["groups"], report["problems"]), ({}, [], []))
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,237 @@
import io
import sys
import tempfile
import unittest
from contextlib import redirect_stderr
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
import setup_check # noqa: E402
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
REQUIRES_BMAD = f'required_skills = [{{ skill = "bmad", version = "6.13.0", source = "{SOURCE}" }}]\n'
def write_bmod(folder: Path, *, code: str, version: str, skills: list[str], extra: str = "") -> None:
folder.mkdir(parents=True, exist_ok=True)
listed = ", ".join(f'"{name}"' for name in skills)
(folder / "bmod.toml").write_text(
f'[bmod]\ncode = "{code}"\nversion = "{version}"\nupdate_source = "{SOURCE}"\nskills = [{listed}]\n{extra}',
encoding="utf-8",
)
def write_skill(folder: Path, *, bmod: str, source: str = SOURCE, extra: str = "") -> None:
folder.mkdir(parents=True, exist_ok=True)
(folder / "bmod.toml").write_text(f'[skill]\nbmod = "{bmod}"\nsource = "{source}"\n{extra}', encoding="utf-8")
class OwedSetupTests(unittest.TestCase):
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.project = Path(self.temp.name) / "project"
self.skills = self.project / ".claude" / "skills"
(self.project / "_bmad").mkdir(parents=True)
(self.project / "_bmad" / "config.toml").write_text("[core]\n", encoding="utf-8")
def hub(self, version: str) -> None:
write_bmod(self.skills / "bmod-core-tools", code="core-tools", version=version, skills=["bmad"])
write_skill(self.skills / "bmad", bmod="bmod-core-tools")
def skill(self, *, module_extra: str = "", skill_extra: str = "") -> Path:
write_bmod(self.skills / "bmod-demo", code="demo", version="1.0.0", skills=["demo-skill"], extra=module_extra)
folder = self.skills / "demo-skill"
write_skill(folder, bmod="bmod-demo", extra=skill_extra)
return folder
def test_nothing_is_owed_when_requirements_are_met(self):
self.hub("6.13.0")
folder = self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_skill_without_a_bmod_file_owes_nothing(self):
folder = self.skills / "plain-skill"
folder.mkdir(parents=True)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_module_record_owes_nothing(self):
self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(setup_check.owed(self.skills / "bmod-demo", self.project), [])
def test_a_missing_module_record_is_named_with_its_install_command(self):
folder = self.skills / "demo-skill"
write_skill(folder, bmod="bmod-demo", source="github:acme/demo/skills")
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`bmod-demo`", note)
self.assertIn("`npx skills add acme/demo --skill bmod-demo`", note)
def test_a_skill_without_its_record_still_reports_its_own_requirements(self):
folder = self.skills / "demo-skill"
write_skill(folder, bmod="bmod-demo", extra='required_skills = ["other-skill"]\n')
record, requirement = setup_check.owed(folder, self.project)
self.assertIn("`bmod-demo`", record)
self.assertIn("`npx skills add bmad-code-org/BMAD-METHOD --skill other-skill`", requirement)
def test_an_older_required_skill_is_reported_with_both_versions(self):
self.hub("6.12.0")
folder = self.skill(module_extra=REQUIRES_BMAD)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`bmad` 6.13.0 or later", note)
self.assertIn("6.12.0 is installed", note)
def test_the_next_build_of_the_required_version_meets_it(self):
self.hub("6.13.0-next")
folder = self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_the_next_build_of_an_earlier_version_does_not(self):
self.hub("6.12.0-next")
folder = self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
def test_a_required_skill_without_its_module_record_is_not_called_outdated(self):
write_skill(self.skills / "bmad", bmod="bmod-core-tools")
folder = self.skill(module_extra=REQUIRES_BMAD)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_required_skill_with_no_bmod_file_is_reported_with_an_unreadable_version(self):
(self.skills / "bmad").mkdir(parents=True)
folder = self.skill(module_extra=REQUIRES_BMAD)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("version cannot be read", note)
self.assertIn("npx skills update", note)
def test_a_requirement_may_carry_no_version(self):
(self.skills / "other-skill").mkdir(parents=True)
folder = self.skill(skill_extra='required_skills = ["other-skill"]\n')
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_the_modules_requirements_and_the_skills_own_are_both_checked(self):
folder = self.skill(
module_extra='required_skills = ["module-need"]\n',
skill_extra='required_skills = ["skill-need", "module-need"]\n',
)
notes = setup_check.owed(folder, self.project)
self.assertEqual(len(notes), 2)
self.assertIn("`module-need`", notes[0])
self.assertIn("`skill-need`", notes[1])
def test_a_skill_required_by_both_lists_is_held_to_the_higher_minimum(self):
self.hub("6.13.0")
higher = f'required_skills = [{{ skill = "bmad", version = "6.14.0", source = "{SOURCE}" }}]\n'
for module_extra, skill_extra in (
(REQUIRES_BMAD, higher),
(higher, REQUIRES_BMAD),
('required_skills = ["bmad"]\n', higher),
):
with self.subTest(module=module_extra):
folder = self.skill(module_extra=module_extra, skill_extra=skill_extra)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`bmad` 6.14.0 or later", note)
def test_a_requirement_that_names_the_skill_itself_is_skipped(self):
folder = self.skill(module_extra='required_skills = ["demo-skill"]\n')
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_missing_required_skill_names_its_install_command(self):
folder = self.skill(
skill_extra='required_skills = [{ skill = "other-skill", version = "2.0.0", source = "github:acme/tools/skills" }]\n'
)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`npx skills add acme/tools --skill other-skill`", note)
def test_a_missing_required_skill_defaults_to_the_declaring_files_source(self):
folder = self.skill(skill_extra='required_skills = ["bmad"]\n')
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`npx skills add bmad-code-org/BMAD-METHOD --skill bmad`", note)
def test_recommended_skills_are_never_reported(self):
folder = self.skill(
module_extra='recommended_skills = ["module-extra"]\n',
skill_extra='recommended_skills = ["other-skill"]\n',
)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_an_unanswered_question_is_reported_and_an_empty_answer_is_not(self):
question = (
'\n[[bmod.config_questions]]\nkey = "active_initiative"\nprompt = "Active initiative?"\ndefault = ""\n'
)
folder = self.skill(module_extra=question)
(note,) = setup_check.owed(folder, self.project)
self.assertIn("active_initiative", note)
self.assertIn("`bmad setup`", note)
(self.project / "_bmad" / "config.toml").write_text(
'[core]\n\n[modules.demo]\nactive_initiative = ""\n', encoding="utf-8"
)
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_user_question_is_answered_by_the_users_own_file(self):
question = (
'\n[[bmod.config_questions]]\nkey = "note_style"\nscope = "user"\nprompt = "Style?"\ndefault = "bullets"\n'
)
folder = self.skill(module_extra=question)
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
custom = self.project / "_bmad" / "custom"
custom.mkdir()
(custom / "config.user.toml").write_text('[modules.demo]\nnote_style = "prose"\n', encoding="utf-8")
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_missing_or_stale_placed_script_is_reported(self):
folder = self.skill(skill_extra='scripts = ["scripts/tool.py"]\n')
(folder / "scripts").mkdir()
(folder / "scripts" / "tool.py").write_bytes(b"new\n")
(note,) = setup_check.owed(folder, self.project)
self.assertIn("`_bmad/demo/scripts/`", note)
self.assertIn("`bmad setup`", note)
placed = self.project / "_bmad" / "demo" / "scripts" / "tool.py"
placed.parent.mkdir(parents=True)
placed.write_bytes(b"old\n")
self.assertEqual(len(setup_check.owed(folder, self.project)), 1)
placed.write_bytes(b"new\n")
self.assertEqual(setup_check.owed(folder, self.project), [])
def test_a_single_skill_module_reads_both_tables_from_its_own_file(self):
folder = self.skills / "release-notes"
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text(
'[bmod]\ncode = "notes"\nversion = "1.0.0"\nupdate_source = "github:acme/release-notes"\n'
'required_skills = ["other-skill"]\n\n'
'[[bmod.config_questions]]\nkey = "path"\nprompt = "Path?"\ndefault = "x"\n\n[skill]\n',
encoding="utf-8",
)
requirement, question = setup_check.owed(folder, self.project)
self.assertIn("`npx skills add acme/release-notes --skill other-skill`", requirement)
self.assertIn("module `notes`", question)
def test_report_writes_each_note_as_an_instruction(self):
folder = self.skills / "demo-skill"
write_skill(folder, bmod="bmod-demo")
stream = io.StringIO()
with redirect_stderr(stream):
setup_check.report(folder, self.project)
self.assertTrue(stream.getvalue().startswith("setup: before continuing, tell the user that `demo-skill` "))
def test_report_stays_silent_when_a_bmod_file_cannot_be_parsed(self):
folder = self.skills / "broken-skill"
folder.mkdir(parents=True)
(folder / "bmod.toml").write_text("not toml [", encoding="utf-8")
stream = io.StringIO()
with redirect_stderr(stream):
setup_check.report(folder, self.project)
self.assertEqual(stream.getvalue(), "")
write_skill(folder, bmod="bmod-demo")
(self.skills / "bmod-demo").mkdir()
(self.skills / "bmod-demo" / "bmod.toml").write_text("not toml [", encoding="utf-8")
with redirect_stderr(stream):
setup_check.report(folder, self.project)
self.assertEqual(stream.getvalue(), "")
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,357 @@
import contextlib
import importlib.util
import io
import subprocess
import sys
import tempfile
import tomllib
import unittest
from pathlib import Path
SCRIPTS = Path(__file__).resolve().parents[1]
STAMPER = SCRIPTS / "stamp_release.py"
SETUP_PY = SCRIPTS / "setup.py"
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
RECORD = (
"[bmod]\n"
'code = "{code}"\n'
'version = "{version}"\n'
f'update_source = "{SOURCE}"\n'
"skills = [{skills}]\n"
'pre_install_message = ""\n'
'post_install_message = ""\n'
"\n"
"[[bmod.knowledge]]\n"
'path = "extra.md"\n'
)
SKILL = f'[skill]\nbmod = "{{bmod}}"\nsource = "{SOURCE}"\n'
MEMBERS = {"method": ("bmad-build", "bmad-spec"), "core-tools": ("bmad", "bmad-flow")}
def load_module(name: str, path: Path):
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
sr = load_module("stamp_release", STAMPER)
setup = load_module("bmad_setup_stamp_contract", SETUP_PY)
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def record_text(code: str, version: str) -> str:
skills = ", ".join(f'"{skill}"' for skill in MEMBERS[code])
return RECORD.format(code=code, version=version, skills=skills)
def make_tree(root: Path, version: str = "6.11.0-next") -> None:
for code, members in MEMBERS.items():
folder = root / "skills" / f"bmod-{code}"
write(folder / "bmod.toml", record_text(code, version))
write(folder / "help" / "help.md", "# help\n")
write(folder / "extra.md", "# extra\n")
write(folder / "SKILL.md", "# record\n")
for skill in members:
write(root / "skills" / skill / "bmod.toml", SKILL.format(bmod=f"bmod-{code}"))
def snapshot(root: Path) -> dict[str, bytes]:
return {path.relative_to(root).as_posix(): path.read_bytes() for path in sorted(root.rglob("*")) if path.is_file()}
def run_stamper(root: Path, version: str) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = sr.run(root, version)
return code, out.getvalue(), err.getvalue()
class StampReleaseTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name).resolve()
self.method = self.root / "skills" / "bmod-method" / "bmod.toml"
def assert_refused(self, version: str, *expected: str) -> None:
before = snapshot(self.root)
code, _, err = run_stamper(self.root, version)
self.assertEqual(code, 1)
for text in expected:
self.assertIn(text, err)
self.assertEqual(snapshot(self.root), before)
def test_happy_path_stamps_every_record_and_no_member_skill(self):
make_tree(self.root)
before = snapshot(self.root)
code, out, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
after = snapshot(self.root)
changed = sorted(name for name in after if after[name] != before[name])
self.assertEqual(changed, ["skills/bmod-core-tools/bmod.toml", "skills/bmod-method/bmod.toml"])
self.assertEqual(self.method.read_text(encoding="utf-8"), record_text("method", "1.2.0"))
self.assertIn("Stamped version 1.2.0 into 2 files", out)
self.assertIn("skills/bmod-method/bmod.toml", out)
self.assertIn("skills/bmod-core-tools/bmod.toml", out)
self.assertNotIn("skills/bmad-build/bmod.toml", out)
def test_single_skill_module_is_stamped(self):
make_tree(self.root)
notes = self.root / "skills" / "release-notes" / "bmod.toml"
write(
notes,
f'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n'
'pre_install_message = ""\npost_install_message = ""\n\n'
f'[skill]\nrequired_skills = [{{ skill = "x", version = "1.0.0", source = "{SOURCE}" }}]\n',
)
write(notes.parent / "SKILL.md", "# notes\n")
code, out, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
data = tomllib.loads(notes.read_text(encoding="utf-8"))
self.assertEqual(data["bmod"]["version"], "1.2.0")
self.assertEqual(data["skill"]["required_skills"][0]["version"], "1.0.0")
self.assertIn("into 3 files", out)
def test_non_semver_version_touches_nothing(self):
make_tree(self.root)
self.assert_refused("banana", "SemVer")
def test_dev_prerelease_rejected_with_explanation(self):
make_tree(self.root)
self.assert_refused("1.2.0-dev", "-dev", "current")
def test_build_metadata_rejected_with_explanation(self):
make_tree(self.root)
self.assert_refused("1.2.0+hotfix", "build metadata", "'1.2.0'")
def test_build_metadata_on_prerelease_rejected(self):
make_tree(self.root)
self.assert_refused("1.2.0-rc.1+build.5", "build metadata")
def test_orderable_prerelease_is_accepted(self):
make_tree(self.root)
code, _, err = run_stamper(self.root, "6.12.0-next.1")
self.assertEqual(code, 0, err)
self.assertEqual(tomllib.loads(self.method.read_text(encoding="utf-8"))["bmod"]["version"], "6.12.0-next.1")
def test_repository_check_failure_names_the_file_and_touches_nothing(self):
make_tree(self.root)
write(self.root / "skills" / "bmad-orphan" / "SKILL.md", "# orphan\n")
self.assert_refused("1.2.0", "skills/bmad-orphan: missing bmod.toml")
def test_record_missing_version_key_names_file_and_touches_nothing(self):
make_tree(self.root)
write(self.method, record_text("method", "x").replace('version = "x"\n', ""))
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "'bmod.version'")
def test_every_problem_is_reported_in_one_run(self):
make_tree(self.root)
write(self.root / "skills" / "bmad-orphan" / "SKILL.md", "# orphan\n")
(self.root / "skills" / "bmod-method" / "help" / "help.md").unlink()
self.assert_refused("1.2.0", "skills/bmad-orphan", "skills/bmod-method/help/help.md")
def test_empty_skills_tree_reports_error(self):
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 1)
self.assertIn("bmod.toml", err)
def test_tree_with_no_module_record_reports_error(self):
write(self.root / "skills" / "plain" / "bmod.toml", '[skill]\nbmod = "bmod-x"\nsource = "github:o/r"\n')
self.assert_refused("1.2.0", "[skill] bmod names 'bmod-x', which is not a module record")
def test_keys_and_tables_the_stamper_does_not_know_are_left_alone(self):
make_tree(self.root)
text = record_text("method", "6.11.0-next").replace(
'code = "method"\n', 'code = "method"\nfuture_field = ["anything"]\n'
)
extra = '\n[bmod.builder]\nversion = "9.9.9"\n\n[other]\nversion = "8.8.8"\nnote = "anything"\n'
write(self.method, text + extra)
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
self.assertEqual(
self.method.read_text(encoding="utf-8"),
text.replace('version = "6.11.0-next"', 'version = "1.2.0"') + extra,
)
def test_version_in_a_table_before_bmod_is_left_alone(self):
make_tree(self.root)
text = '[other]\nversion = "8.8.8"\n\n' + record_text("method", "6.11.0-next")
write(self.method, text)
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
data = tomllib.loads(self.method.read_text(encoding="utf-8"))
self.assertEqual((data["other"]["version"], data["bmod"]["version"]), ("8.8.8", "1.2.0"))
def test_requirement_versions_are_left_alone(self):
make_tree(self.root)
line = f'required_skills = [{{ skill = "bmad", version = "6.13.0", source = "{SOURCE}" }}]\n'
text = record_text("method", "6.11.0-next").replace("\n[[bmod.knowledge]]", line + "\n[[bmod.knowledge]]")
write(self.method, text)
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
stamped = self.method.read_text(encoding="utf-8")
self.assertIn(line, stamped)
self.assertIn('version = "1.2.0"\n', stamped)
def test_indentation_comment_and_line_endings_are_preserved(self):
make_tree(self.root)
text = record_text("method", "6.11.0-next").replace(
'version = "6.11.0-next"\n', ' version = "6.11.0-next" # stamped\n'
)
self.method.write_bytes(text.replace("\n", "\r\n").encode("utf-8"))
code, _, err = run_stamper(self.root, "1.2.0")
self.assertEqual(code, 0, err)
expected = text.replace('"6.11.0-next"', '"1.2.0"').replace("\n", "\r\n")
self.assertEqual(self.method.read_bytes(), expected.encode("utf-8"))
def test_version_the_stamper_cannot_find_as_a_line_touches_nothing(self):
make_tree(self.root)
write(self.method, record_text("method", "x").replace('version = "x"', "version = '6.11.0-next'"))
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "expected exactly one 'version = \"...\"' line")
def test_version_lookalike_inside_a_multi_line_string_touches_nothing(self):
make_tree(self.root)
note = 'note = """\nversion = "x"\n"""\n'
text = record_text("method", "6.11.0-next").replace('code = "method"\n', 'code = "method"\n' + note)
write(self.method, text)
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "line inside [bmod], found 2")
def test_only_version_line_being_inside_a_string_touches_nothing(self):
make_tree(self.root)
note = 'note = """\nversion = "x"\n"""\n'
text = record_text("method", "x").replace('version = "x"\n', "version = '6.11.0-next'\n" + note)
write(self.method, text)
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "would change something other than")
def test_bmod_written_without_a_table_header_touches_nothing(self):
make_tree(self.root)
write(
self.method,
f'bmod = {{ code = "method", version = "6.11.0-next", update_source = "{SOURCE}" }}\n',
)
for skill in MEMBERS["method"]:
(self.root / "skills" / skill / "bmod.toml").unlink()
(self.root / "skills" / skill).rmdir()
self.assert_refused("1.2.0", "skills/bmod-method/bmod.toml", "expected exactly one '[bmod]' table header")
class CheckModeTests(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name).resolve()
make_tree(self.root)
def check(self, *argv: str) -> tuple[int, str, str]:
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = sr.main([*argv, "--project-root", str(self.root)])
return code, out.getvalue(), err.getvalue()
def test_check_validates_and_writes_nothing(self):
before = snapshot(self.root)
code, out, err = self.check("--check")
self.assertEqual(code, 0, err)
self.assertIn("bmod files valid:", out)
self.assertEqual(snapshot(self.root), before)
def test_check_fails_on_a_broken_tree(self):
(self.root / "skills" / "bmod-method" / "SKILL.md").unlink()
before = snapshot(self.root)
code, _, err = self.check("--check")
self.assertEqual(code, 1)
self.assertIn("skills/bmod-method: a module record folder must ship SKILL.md", err)
self.assertEqual(snapshot(self.root), before)
def test_version_and_check_together_is_a_usage_error(self):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
sr.main(["1.2.0", "--check"])
self.assertEqual(raised.exception.code, 2)
def test_neither_version_nor_check_is_a_usage_error(self):
with contextlib.redirect_stderr(io.StringIO()), self.assertRaises(SystemExit) as raised:
sr.main([])
self.assertEqual(raised.exception.code, 2)
def test_run_from_the_repository_root_checks_the_current_directory(self):
result = subprocess.run(
[sys.executable, "-B", str(STAMPER), "--check"], cwd=self.root, capture_output=True, text=True, check=False
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("bmod files valid:", result.stdout)
class StampedContentTests(unittest.TestCase):
def test_only_the_bmod_table_version_changes(self):
original = '[a]\nversion = "1"\n[bmod]\ncode = "x"\nversion = "2"\n[[bmod.knowledge]]\nversion = "3"\n'
stamped = sr.validator.stamp_text(original, "9.9.9")
self.assertEqual(stamped, original.replace('version = "2"', 'version = "9.9.9"'))
def test_two_version_lines_inside_bmod_are_refused(self):
with self.assertRaises(ValueError):
sr.validator.stamp_text('[bmod]\nversion = "1"\nversion = "2"\n', "9.9.9")
class InstallerContractTests(unittest.TestCase):
"""Pin the version rules the stamper adds on top of skills/bmad/scripts/setup.py.
If setup.py's rules drift, these fail instead of shipping a release an
installed module cannot order.
"""
def test_stamper_uses_the_runtime_semver(self):
self.assertEqual(sr.setup.SEMVER.pattern, setup.SEMVER.pattern)
def test_validate_version_accepts_exactly_what_setup_can_distinguish(self):
"""Accept a version only if setup.py can both order it and tell it apart.
Build metadata is orderable but not distinguishing: setup.py drops it,
so `1.2.0+hotfix` compares equal to `1.2.0` and a release stamped that
way is invisible to an installed module. The stamper is stricter than
orderability by exactly that much.
"""
candidates = (
"1.2.0",
"0.0.1",
"6.12.0-next.1",
"1.2.0-rc.1+build.5",
"1.2.0+hotfix",
"banana",
"1.2",
"01.2.0",
"1.2.0-dev",
"1.2.0-DEV",
"1.2.0-dev.1",
"",
)
for version in candidates:
parsed = setup.parse_orderable_semver(version)
distinguishable = parsed is not None and "+" not in version
try:
sr.validate_version(version)
accepted = True
except sr.StampError:
accepted = False
self.assertEqual(accepted, distinguishable, version)
def test_setup_orders_build_metadata_as_equal(self):
"""The premise of the rejection above, pinned against setup.py itself."""
self.assertEqual(
setup.parse_orderable_semver("1.2.0+hotfix"),
setup.parse_orderable_semver("1.2.0"),
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,553 @@
import contextlib
import importlib.util
import io
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
VALIDATOR = Path(__file__).resolve().parents[1] / "validate_manifests.py"
SOURCE = "github:bmad-code-org/BMAD-METHOD/skills"
MESSAGES = 'pre_install_message = ""\npost_install_message = ""\n'
METHOD_RECORD = (
"[bmod]\n"
'code = "method"\n'
'version = "{version}"\n'
f'update_source = "{SOURCE}"\n'
'skills = ["bmad-build", "bmad-spec"]\n'
'required_skills = ["bmad"]\n'
f"{MESSAGES}"
"\n"
"[[bmod.knowledge]]\n"
'path = "delivery-help.md"\n'
'skills = ["bmad-build"]\n'
)
CORE_TOOLS_RECORD = (
"[bmod]\n"
'code = "core-tools"\n'
'version = "{version}"\n'
f'update_source = "{SOURCE}"\n'
'skills = ["bmad", "bmad-flow"]\n'
f"{MESSAGES}"
)
SKILL = f'[skill]\nbmod = "{{bmod}}"\nsource = "{SOURCE}"\n'
ROSTER = (
"[[members]]\n"
'code = "bmad-build"\n'
'skill = "bmad-build"\n'
'name = "Amelia"\n'
"\n"
"[[members]]\n"
'code = "guest"\n'
'name = "Guest"\n'
"\n"
"[[groups]]\n"
'id = "team"\n'
'members = ["bmad-build", "guest"]\n'
)
MEMBERS = {"bmod-method": ("bmad-build", "bmad-spec"), "bmod-core-tools": ("bmad", "bmad-flow")}
def load_module(name: str, path: Path):
sys.dont_write_bytecode = True
spec = importlib.util.spec_from_file_location(name, path)
module = importlib.util.module_from_spec(spec)
assert spec.loader is not None
spec.loader.exec_module(module)
return module
vm = load_module("validate_manifests", VALIDATOR)
def write(path: Path, content: str) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content, encoding="utf-8")
def make_tree(root: Path, version: str = "6.11.0-next") -> None:
skills = root / "skills"
write(skills / "bmod-method" / "bmod.toml", METHOD_RECORD.format(version=version))
write(skills / "bmod-method" / "help" / "help.md", "# help\n")
write(skills / "bmod-method" / "delivery-help.md", "# delivery\n")
write(skills / "bmod-method" / "roster.toml", ROSTER)
write(skills / "bmod-core-tools" / "bmod.toml", CORE_TOOLS_RECORD.format(version=version))
write(skills / "bmod-core-tools" / "help" / "help.md", "# help\n")
for bmod, members in MEMBERS.items():
write(skills / bmod / "SKILL.md", "# record\n")
for skill in members:
write(skills / skill / "bmod.toml", SKILL.format(bmod=bmod))
write(skills / skill / "SKILL.md", "# skill\n")
class ValidatorCase(unittest.TestCase):
def setUp(self):
self._tmp = tempfile.TemporaryDirectory()
self.addCleanup(self._tmp.cleanup)
self.root = Path(self._tmp.name).resolve()
self.skills = self.root / "skills"
make_tree(self.root)
def problems(self) -> str:
return "\n".join(vm.check_repo(self.root).problems)
def method_record(self, old: str, new: str) -> None:
path = self.skills / "bmod-method" / "bmod.toml"
text = path.read_text(encoding="utf-8")
self.assertIn(old, text)
write(path, text.replace(old, new))
class CleanTreeTests(ValidatorCase):
def test_clean_tree_has_no_problems_and_reports_its_records(self):
report = vm.check_repo(self.root)
self.assertEqual(report.problems, ())
self.assertEqual(
[path.relative_to(self.root).as_posix() for path in report.records],
["skills/bmod-core-tools/bmod.toml", "skills/bmod-method/bmod.toml"],
)
self.assertEqual((report.skills, report.documents), (4, 3))
def test_unknown_keys_and_tables_are_left_alone(self):
self.method_record('code = "method"\n', 'code = "method"\nfuture_field = ["anything"]\n')
path = self.skills / "bmod-method" / "bmod.toml"
write(path, path.read_text(encoding="utf-8") + '\n[builder]\nversion = "9.9.9"\n')
write(
self.skills / "bmad-spec" / "bmod.toml",
SKILL.format(bmod="bmod-method") + "later = true\n\n[extra]\nx = 1\n",
)
self.assertEqual(self.problems(), "")
def test_single_skill_module_is_valid_under_any_folder_name(self):
write(
self.skills / "release-notes" / "bmod.toml",
'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "github:acme/notes"\n'
f"{MESSAGES}\n[skill]\n",
)
write(self.skills / "release-notes" / "help" / "help.md", "# help\n")
write(self.skills / "release-notes" / "SKILL.md", "# notes\n")
self.assertEqual(self.problems(), "")
def test_module_with_no_skills_is_valid(self):
write(
self.skills / "bmod-rooms" / "bmod.toml",
f'[bmod]\ncode = "rooms"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n{MESSAGES}',
)
write(self.skills / "bmod-rooms" / "help" / "help.md", "# help\n")
write(self.skills / "bmod-rooms" / "SKILL.md", "# rooms\n")
self.assertEqual(self.problems(), "")
def test_empty_skills_tree_is_a_problem(self):
empty = self.root / "empty"
empty.mkdir()
self.assertIn("no skills/*/bmod.toml found", "\n".join(vm.check_repo(empty).problems))
def test_main_exit_codes(self):
out, err = io.StringIO(), io.StringIO()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = vm.main(["--project-root", str(self.root)])
self.assertEqual(code, 0, err.getvalue())
self.assertIn("4 skills, 2 module records, 3 knowledge documents", out.getvalue())
(self.skills / "bmad-flow" / "bmod.toml").unlink()
with contextlib.redirect_stdout(out), contextlib.redirect_stderr(err):
code = vm.main(["--project-root", str(self.root)])
self.assertEqual(code, 1)
self.assertIn("skills/bmad-flow: missing bmod.toml", err.getvalue())
def test_run_from_outside_the_bmad_scripts_checks_the_current_directory(self):
result = subprocess.run(
[sys.executable, "-B", str(VALIDATOR)], cwd=self.root, capture_output=True, text=True, check=False
)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn("bmod files valid: 4 skills, 2 module records", result.stdout)
(self.skills / "bmad-flow" / "bmod.toml").unlink()
result = subprocess.run(
[sys.executable, "-B", str(VALIDATOR)], cwd=self.root, capture_output=True, text=True, check=False
)
self.assertEqual(result.returncode, 1)
self.assertIn("skills/bmad-flow: missing bmod.toml", result.stderr)
class FileRuleTests(ValidatorCase):
def test_skill_folder_without_a_bmod_file(self):
write(self.skills / "bmad-orphan" / "SKILL.md", "# orphan\n")
self.assertIn("skills/bmad-orphan: missing bmod.toml", self.problems())
def test_file_the_runtime_parser_rejects(self):
write(self.skills / "bmad-spec" / "bmod.toml", 'note = "neither table"\n')
problems = self.problems()
self.assertIn("skills/bmad-spec/bmod.toml: the runtime parser rejects this file", problems)
self.assertIn("[bmod] table, a [skill] table, or both", problems)
def test_record_missing_a_required_key(self):
self.method_record('version = "6.11.0-next"\n', "")
problems = self.problems()
self.assertIn("skills/bmod-method/bmod.toml", problems)
self.assertIn("'bmod.version'", problems)
def test_record_missing_an_install_message_key(self):
for key in ("pre_install_message", "post_install_message"):
with self.subTest(key=key):
make_tree(self.root)
self.method_record(f'{key} = ""\n', "")
self.assertIn(f"skills/bmod-method/bmod.toml: [bmod] is missing {key!r}", self.problems())
def test_install_message_that_is_not_a_string(self):
self.method_record('pre_install_message = ""\n', "pre_install_message = 3\n")
problems = self.problems()
self.assertIn("skills/bmod-method/bmod.toml: the runtime parser rejects this file", problems)
self.assertIn("'bmod.pre_install_message' must be a string", problems)
def test_skill_missing_a_required_key(self):
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\n')
problems = self.problems()
self.assertIn("skills/bmad-spec/bmod.toml", problems)
self.assertIn("'skill.source'", problems)
def test_github_source_with_two_parts_is_accepted(self):
self.method_record(f'update_source = "{SOURCE}"', 'update_source = "github:o/r"')
for skill in MEMBERS["bmod-method"]:
write(self.skills / skill / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
self.assertEqual(self.problems(), "")
def test_github_source_with_one_part_is_rejected(self):
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o"\n')
self.assertIn("github source must name owner/repo", self.problems())
class RecordRuleTests(ValidatorCase):
def test_record_folder_not_named_after_its_code(self):
(self.skills / "bmod-method").rename(self.skills / "bmod-bmad-method")
for skill in MEMBERS["bmod-method"]:
write(self.skills / skill / "bmod.toml", SKILL.format(bmod="bmod-bmad-method"))
self.assertIn(
"skills/bmod-bmad-method/bmod.toml: a module record folder is named 'bmod-method'", self.problems()
)
def test_second_record_for_one_code(self):
write(
self.skills / "bmod-zeta" / "bmod.toml",
f'[bmod]\ncode = "method"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
)
self.assertIn(
"skills/bmod-zeta/bmod.toml: module code 'method' is already declared by skills/bmod-method/bmod.toml",
self.problems(),
)
def test_codes_differing_only_by_case(self):
write(
self.skills / "upper" / "bmod.toml",
f'[bmod]\ncode = "Method"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n\n[skill]\n',
)
self.assertIn("module code 'Method' is already declared by", self.problems())
def test_records_with_different_versions(self):
self.method_record('version = "6.11.0-next"', 'version = "6.12.0"')
problems = self.problems()
self.assertIn("every module record carries one version", problems)
self.assertIn("bmod-method has '6.12.0'", problems)
def test_bmod_folder_without_a_bmod_table(self):
write(self.skills / "bmod-x" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
self.assertIn("skills/bmod-x/bmod.toml: a bmod-* folder holds a module record", self.problems())
def test_record_version_that_is_not_semver(self):
self.method_record('version = "6.11.0-next"', 'version = "1.0"')
self.assertIn("skills/bmod-method/bmod.toml: [bmod] invalid version '1.0': must be SemVer", self.problems())
def test_record_version_the_runtime_cannot_order(self):
for version in ("6.11.0-dev", "6.11.0+build.1"):
with self.subTest(version=version):
self.method_record('version = "6.11.0-next"', f'version = "{version}"')
self.assertIn(f"skills/bmod-method/bmod.toml: [bmod] invalid version {version!r}", self.problems())
self.method_record(f'version = "{version}"', 'version = "6.11.0-next"')
def test_record_without_skill_md(self):
(self.skills / "bmod-method" / "SKILL.md").unlink()
self.assertIn("skills/bmod-method: a module record folder must ship SKILL.md", self.problems())
class RetiredRuleTests(ValidatorCase):
def retire(self, record: str, lines: str) -> None:
write(self.skills / record / "retired.toml", lines)
def test_retired_names_that_no_longer_ship_are_valid(self):
self.retire(
"bmod-method", 'renamed = [{ from = "bmad-old-build", to = "bmad-build" }]\nremoved = ["bmad-gone"]\n'
)
self.assertEqual(self.problems(), "")
def test_a_retired_name_that_still_ships_is_a_problem(self):
self.retire("bmod-method", 'removed = ["bmad-spec"]\n')
self.assertIn("retires 'bmad-spec', but skills/bmad-spec still ships", self.problems())
def test_a_rename_to_a_skill_the_repository_does_not_ship_is_a_problem(self):
self.retire("bmod-method", 'renamed = [{ from = "bmad-old", to = "bmad-missing" }]\n')
self.assertIn("renames 'bmad-old' to 'bmad-missing', which this repository does not ship", self.problems())
def test_a_name_retired_by_two_records_is_a_problem(self):
self.retire("bmod-method", 'removed = ["bmad-gone"]\n')
self.retire("bmod-core-tools", 'removed = ["bmad-gone"]\n')
self.assertIn("retires 'bmad-gone', which skills/bmod-", self.problems())
def test_two_renames_to_one_skill_are_a_problem(self):
self.retire(
"bmod-method",
'renamed = [{ from = "bmad-a", to = "bmad-build" }, { from = "bmad-b", to = "bmad-build" }]\n',
)
self.assertIn("renames more than one skill to 'bmad-build'", self.problems())
def test_a_retired_file_the_runtime_rejects_is_a_problem(self):
self.retire("bmod-method", 'removed = ["bmad-gone", "bmad-gone"]\n')
self.assertIn("skills/bmod-method/retired.toml: the runtime parser rejects this file", self.problems())
class StampRuleTests(ValidatorCase):
def test_record_the_stamper_cannot_stamp(self):
self.method_record('version = "6.11.0-next"', "version = '6.11.0-next'")
self.assertIn(
"skills/bmod-method/bmod.toml: stamp_release.py cannot stamp this file: expected exactly one "
"'version = \"...\"' line inside [bmod], found 0",
self.problems(),
)
def test_version_lookalike_inside_a_multi_line_string(self):
self.method_record('code = "method"\n', 'code = "method"\nnote = """\nversion = "x"\n"""\n')
self.assertIn("cannot stamp this file", self.problems())
def test_the_check_writes_nothing(self):
path = self.skills / "bmod-method" / "bmod.toml"
before = path.read_bytes()
self.assertEqual(self.problems(), "")
self.assertEqual(path.read_bytes(), before)
class MembershipRuleTests(ValidatorCase):
def test_skill_naming_a_record_the_repo_lacks(self):
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmod-absent"))
self.assertIn(
"skills/bmad-spec/bmod.toml: [skill] bmod names 'bmod-absent', which is not a module record",
self.problems(),
)
def test_skill_naming_a_folder_that_is_not_a_record(self):
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmad-build"))
self.assertIn("[skill] bmod names 'bmad-build', which is not a module record", self.problems())
def test_skill_its_record_does_not_list(self):
write(self.skills / "bmad-extra" / "bmod.toml", SKILL.format(bmod="bmod-method"))
self.assertIn(
"skills/bmad-extra/bmod.toml: [skill] bmod names 'bmod-method', but skills/bmod-method/bmod.toml "
"does not list 'bmad-extra'",
self.problems(),
)
def test_listed_skill_the_repo_does_not_ship(self):
self.method_record('["bmad-build", "bmad-spec"]', '["bmad-build", "bmad-spec", "bmad-typo"]')
self.assertIn(
"skills/bmod-method/bmod.toml: lists the skill 'bmad-typo', which this repository does not ship",
self.problems(),
)
def test_listed_skill_that_names_another_record(self):
write(self.skills / "bmad-spec" / "bmod.toml", SKILL.format(bmod="bmod-core-tools"))
self.assertIn(
"skills/bmod-method/bmod.toml: lists the skill 'bmad-spec', but skills/bmad-spec/bmod.toml names "
"'bmod-core-tools' as its bmod",
self.problems(),
)
def test_listed_folder_that_is_a_record(self):
self.method_record('["bmad-build", "bmad-spec"]', '["bmad-build", "bmad-spec", "bmod-core-tools"]')
self.assertIn("lists 'bmod-core-tools', which is a module record", self.problems())
def test_member_source_differs_from_its_record(self):
write(self.skills / "bmad-spec" / "bmod.toml", '[skill]\nbmod = "bmod-method"\nsource = "github:o/r"\n')
self.assertIn(
f"skills/bmad-spec/bmod.toml: [skill] source 'github:o/r' differs from skills/bmod-method/bmod.toml "
f"update_source {SOURCE!r}",
self.problems(),
)
def test_record_with_skill_table_left_out_of_its_own_list(self):
write(
self.skills / "notes" / "bmod.toml",
f'[bmod]\ncode = "notes"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\nskills = []\n\n[skill]\n',
)
self.assertIn("skills/notes/bmod.toml: holds [skill], but its own [bmod] skills list leaves", self.problems())
class RequirementRuleTests(ValidatorCase):
def add(self, folder: str, line: str) -> None:
path = self.skills / folder / "bmod.toml"
write(path, path.read_text(encoding="utf-8") + line)
def test_plain_names_in_this_repo_are_accepted_in_both_tables(self):
self.add("bmad-build", 'required_skills = ["bmad"]\nrecommended_skills = ["bmad-spec"]\n')
self.assertEqual(self.problems(), "")
def test_required_plain_name_the_repo_lacks(self):
self.add("bmad-build", 'required_skills = ["bmad-typo"]\n')
self.assertIn(
"skills/bmad-build/bmod.toml: skill.required_skills entry 'bmad-typo' names no skill in this repository",
self.problems(),
)
def test_recommended_plain_name_the_repo_lacks(self):
self.add("bmad-build", 'recommended_skills = ["bmad-typo"]\n')
self.assertIn("skill.recommended_skills entry 'bmad-typo' names no skill", self.problems())
def test_record_plain_name_the_repo_lacks(self):
self.method_record('required_skills = ["bmad"]', 'required_skills = ["bmad-typo"]')
self.assertIn(
"skills/bmod-method/bmod.toml: bmod.required_skills entry 'bmad-typo' names no skill", self.problems()
)
def test_table_entry_from_another_repo_is_accepted(self):
self.add(
"bmad-build", 'required_skills = [{ skill = "elsewhere", version = "1.0.0", source = "github:o/r" }]\n'
)
self.assertEqual(self.problems(), "")
def test_table_entry_without_a_source(self):
self.add("bmad-build", 'required_skills = [{ skill = "bmad", version = "6.13.0" }]\n')
self.assertIn("'skill.required_skills[0].source' must be a string", self.problems())
def test_version_with_build_metadata(self):
self.add("bmad-build", f'required_skills = [{{ skill = "bmad", version = "6.13.0+x", source = "{SOURCE}" }}]\n')
problems = self.problems()
self.assertIn("entry 'bmad' version '6.13.0+x' carries build metadata", problems)
self.assertIn("'6.13.0'", problems)
def test_unorderable_version(self):
self.add("bmad-build", f'required_skills = [{{ skill = "bmad", version = "6.13", source = "{SOURCE}" }}]\n')
self.assertIn("'skill.required_skills[0].version' must be an orderable version", self.problems())
class PathRuleTests(ValidatorCase):
def test_help_file_a_bmod_folder_does_not_ship(self):
(self.skills / "bmod-method" / "help" / "help.md").unlink()
self.assertIn(
"skills/bmod-method/help/help.md, which every bmod-* folder holds, the module record does not ship",
self.problems(),
)
def test_help_file_is_optional_outside_a_bmod_folder(self):
write(
self.skills / "solo" / "bmod.toml",
f'[bmod]\ncode = "solo"\nversion = "6.11.0-next"\nupdate_source = "{SOURCE}"\n{MESSAGES}\n[skill]\n',
)
write(self.skills / "solo" / "SKILL.md", "# skill\n")
self.assertEqual(self.problems(), "")
def test_help_symlink(self):
target = self.skills / "bmod-method" / "help" / "help.md"
target.unlink()
target.symlink_to(self.skills / "bmod-method" / "delivery-help.md")
self.assertIn("skills/bmod-method/help/help.md, which every bmod-* folder holds, is a symlink", self.problems())
def test_topic_file_named_in_help_is_valid(self):
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/deep-dive.md`.\n")
write(self.skills / "bmod-method" / "help" / "deep-dive.md", "# deep dive\n")
self.assertEqual(self.problems(), "")
def test_topic_file_help_never_names(self):
write(self.skills / "bmod-method" / "help" / "orphan.md", "# orphan\n")
self.assertIn("skills/bmod-method/help/orphan.md is never named in help/help.md", self.problems())
def test_help_naming_a_topic_file_that_does_not_exist(self):
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/gone.md`.\n")
self.assertIn(
"skills/bmod-method/help/help.md names skills/bmod-method/help/gone.md, which does not exist",
self.problems(),
)
def test_topic_file_naming_a_topic_that_does_not_exist(self):
write(self.skills / "bmod-method" / "help" / "help.md", "# help\n\nSee `help/deep-dive.md`.\n")
write(self.skills / "bmod-method" / "help" / "deep-dive.md", "See `help/gone.md`.\n")
self.assertIn(
"skills/bmod-method/help/deep-dive.md names skills/bmod-method/help/gone.md, which does not exist",
self.problems(),
)
def test_knowledge_naming_the_help_file(self):
self.method_record('path = "delivery-help.md"', 'path = "help/help.md"')
self.assertIn("knowledge names 'help/help.md', which is always read", self.problems())
def test_knowledge_file_the_record_does_not_ship(self):
(self.skills / "bmod-method" / "delivery-help.md").unlink()
self.assertIn(
"skills/bmod-method/bmod.toml: knowledge names 'delivery-help.md', which the module record does not ship",
self.problems(),
)
def test_knowledge_url(self):
self.method_record('path = "delivery-help.md"', 'path = "https://docs.example.com/help.md"')
self.assertIn("unsafe value", self.problems())
def test_knowledge_path_leaving_the_folder(self):
self.method_record('path = "delivery-help.md"', 'path = "../bmad-build/SKILL.md"')
self.assertIn("unsafe value", self.problems())
def test_knowledge_directory(self):
(self.skills / "bmod-method" / "delivery-help.md").unlink()
(self.skills / "bmod-method" / "delivery-help.md").mkdir()
self.assertIn("knowledge names 'delivery-help.md', which is not a regular file", self.problems())
def test_knowledge_symlink(self):
target = self.skills / "bmod-method" / "delivery-help.md"
target.unlink()
target.symlink_to(self.skills / "bmod-method" / "help" / "help.md")
self.assertIn("knowledge names 'delivery-help.md', which is a symlink", self.problems())
def test_knowledge_naming_a_skill_outside_the_module(self):
self.method_record('skills = ["bmad-build"]\n', 'skills = ["bmad-build", "bmad-flow"]\n')
self.assertIn(
"knowledge 'delivery-help.md' names 'bmad-flow', which is not a skill of module 'method'",
self.problems(),
)
def test_a_module_without_a_roster_file_is_valid(self):
(self.skills / "bmod-method" / "roster.toml").unlink()
self.assertEqual(self.problems(), "")
def test_roster_symlink(self):
target = self.skills / "bmod-method" / "roster.toml"
target.unlink()
target.symlink_to(self.skills / "bmod-method" / "help" / "help.md")
self.assertIn("skills/bmod-method/roster.toml is a symlink", self.problems())
def test_roster_that_is_not_toml(self):
write(self.skills / "bmod-method" / "roster.toml", "[[members\n")
self.assertIn("skills/bmod-method/roster.toml: cannot read roster", self.problems())
class RosterContentTests(ValidatorCase):
def roster(self, old: str, new: str) -> None:
self.assertIn(old, ROSTER)
write(self.skills / "bmod-method" / "roster.toml", ROSTER.replace(old, new))
def test_member_code_defined_twice(self):
self.roster('code = "guest"', 'code = "bmad-build"')
self.assertIn("skills/bmod-method/roster.toml: member code 'bmad-build' is defined twice", self.problems())
def test_group_naming_an_undefined_member(self):
self.roster('members = ["bmad-build", "guest"]', 'members = ["bmad-build", "nobody"]')
self.assertIn("group 'team' lists 'nobody', which no member defines", self.problems())
def test_member_skill_the_repo_does_not_ship(self):
self.roster('skill = "bmad-build"', 'skill = "bmad-typo"')
self.assertIn(
"member 'bmad-build' names skill 'bmad-typo', which this repository does not ship", self.problems()
)
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,473 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Check every skills/*/bmod.toml against the runtime that has to read it.
This file owns the repository checks: pre-commit and CI run it, and stamp_release.py beside it imports
`check_repo` and `stamp_text` from it. Keys and tables the runtime does not know are left alone.
It ships with the `bmad` skill, so any module repository can run it against its own tree.
Usage:
uv run skills/bmad/scripts/validate_manifests.py [--project-root <path>]
"""
from __future__ import annotations
import argparse
import copy
import importlib.util
import re
import sys
import tomllib
from pathlib import Path, PurePosixPath
from typing import NamedTuple
sys.dont_write_bytecode = True
SCRIPTS = Path(__file__).resolve().parent
RECORD_PREFIX = "bmod-"
STAMP_PROBE = "0.0.0-stamp-check"
MESSAGE_KEYS = ("pre_install_message", "post_install_message")
TABLE_HEADER = re.compile(r"[ \t]*\[\[?[^\[\]\n]+\]\]?[ \t]*(?:#[^\n]*)?\r?\n?")
BMOD_HEADER = re.compile(r"[ \t]*\[[ \t]*bmod[ \t]*\][ \t]*(?:#[^\n]*)?\r?\n?")
VERSION_LINE = re.compile(r'(?P<head>[ \t]*version[ \t]*=[ \t]*)"[^"\n]*"(?P<tail>[ \t]*(?:#[^\n]*)?\r?\n?)')
def load(name: str, path: Path):
spec = importlib.util.spec_from_file_location(name, path)
if spec is None or spec.loader is None:
raise SystemExit(f"cannot load {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
setup = load("bmad_setup_validate", SCRIPTS / "setup.py")
knowledge = load("bmad_knowledge_validate", SCRIPTS / "knowledge.py")
class RepoReport(NamedTuple):
records: tuple[Path, ...]
skills: int
documents: int
problems: tuple[str, ...]
def check_repo(project_root: Path) -> RepoReport:
skills_dir = project_root / "skills"
folders = sorted(path for path in skills_dir.glob("*") if path.is_dir())
if not folders:
problem = f"no skills/*/{setup.MANIFEST_NAME} found under {project_root}: pass the repository root with --project-root"
return RepoReport((), 0, 0, (problem,))
problems: list[str] = []
files: dict[str, setup.ParsedFile] = {}
for folder in folders:
manifest = folder / setup.MANIFEST_NAME
if not manifest.is_file():
problems.append(f"skills/{folder.name}: missing {setup.MANIFEST_NAME}")
continue
try:
files[folder.name] = setup.parse_bmod_file(manifest, manifest.read_bytes())
except Exception as error:
problems.append(f"{rel(folder.name)}: the runtime parser rejects this file: {error}")
shipped = {folder.name for folder in folders}
records = {name: parsed.bmod for name, parsed in files.items() if parsed.bmod is not None}
members = {name: member_names(name, files[name]) for name in records}
problems += record_problems(files, records, skills_dir)
problems += membership_problems(files, records, members, shipped)
retired: dict[str, setup.ParsedRetired] = {}
for name in records:
try:
retired[name] = setup.read_retired_file(skills_dir / name)
except Exception as error:
problems.append(f"skills/{name}/{setup.RETIRED_NAME}: the runtime parser rejects this file: {error}")
problems += retired_problems(retired, shipped)
for name, parsed in files.items():
for table, source in (("bmod", parsed.bmod), ("skill", parsed.skill)):
if source is not None:
problems += requirement_problems(name, table, source, shipped)
documents = 0
for name, record in records.items():
folder = skills_dir / name
documents += len(record.knowledge) + (folder / knowledge.HELP_NAME).is_file()
problems += knowledge_problems(name, record, folder, members[name])
problems += topic_problems(name, folder)
problems += roster_file_problems(name, record, folder, skills_dir)
problems += stamp_problems(name, folder / setup.MANIFEST_NAME)
problems += message_problems(name, folder / setup.MANIFEST_NAME)
if not problems:
problems += runtime_problems(skills_dir)
record_files = tuple(skills_dir / name / setup.MANIFEST_NAME for name in sorted(records))
skill_count = sum(1 for parsed in files.values() if parsed.skill is not None)
return RepoReport(record_files, skill_count, documents, tuple(problems))
def version_problem(version: str) -> str | None:
"""Why an installed module could not use this record version, or None. The stamper applies the same rule."""
match = setup.SEMVER.fullmatch(version)
if match is None:
return f"invalid version {version!r}: must be SemVer (MAJOR.MINOR.PATCH, optional prerelease), e.g. 6.12.0"
# setup.py refuses to order any version containing "-dev".
if "-dev" in version.casefold():
return (
f'invalid version {version!r}: setup.py cannot order "-dev" '
"versions, so an installed module would never compare as current — "
"pick a different prerelease label"
)
# setup.py drops build metadata when ordering, so "1.2.0+x" compares equal to "1.2.0".
if match.group("build") is not None:
base = version.split("+", 1)[0]
return (
f"invalid version {version!r}: setup.py ignores build metadata when "
f"ordering, so this compares equal to {base!r} and an installed module "
"would never see the release — change the major, minor, patch, or "
"prerelease part"
)
return None
def stamp_text(original: str, version: str) -> str:
"""The file with only the `version` line inside [bmod] rewritten. Raises ValueError when that cannot be done."""
lines = original.splitlines(keepends=True)
headers = [index for index, line in enumerate(lines) if BMOD_HEADER.fullmatch(line)]
if len(headers) != 1:
raise ValueError(f"expected exactly one '[bmod]' table header line, found {len(headers)}")
start = headers[0] + 1
# Only the [bmod] table itself: a table further down may have a `version` of its own.
end = next((index for index in range(start, len(lines)) if TABLE_HEADER.fullmatch(lines[index])), len(lines))
matches = [index for index in range(start, end) if VERSION_LINE.fullmatch(lines[index])]
if len(matches) != 1:
raise ValueError(f"expected exactly one 'version = \"...\"' line inside [bmod], found {len(matches)}")
match = VERSION_LINE.fullmatch(lines[matches[0]])
assert match is not None
lines[matches[0]] = f'{match.group("head")}"{version}"{match.group("tail")}'
stamped = "".join(lines)
if tomllib.loads(stamped) != with_version(tomllib.loads(original), version):
raise ValueError("rewriting the version line would change something other than [bmod] version")
return stamped
def with_version(data: dict, version: str) -> dict:
expected = copy.deepcopy(data)
expected["bmod"]["version"] = version
return expected
def message_problems(name: str, manifest: Path) -> list[str]:
"""Records in this repo carry both install messages, empty or not, so authors see the fields exist."""
table = tomllib.loads(manifest.read_text(encoding="utf-8"))["bmod"]
return [
f"{rel(name)}: [bmod] is missing {key!r}; add it, empty if the module has no message"
for key in MESSAGE_KEYS
if key not in table
]
def stamp_problems(name: str, manifest: Path) -> list[str]:
"""A record the stamper could not stamp fails here, at commit time."""
try:
stamp_text(manifest.read_bytes().decode("utf-8"), STAMP_PROBE)
except (OSError, ValueError) as error:
return [f"{rel(name)}: stamp_release.py cannot stamp this file: {error}"]
return []
def rel(folder: str) -> str:
return f"skills/{folder}/{setup.MANIFEST_NAME}"
def member_names(folder: str, parsed: setup.ParsedFile) -> tuple[str, ...]:
if parsed.bmod.skills is not None:
return parsed.bmod.skills
return (folder,) if parsed.skill is not None else ()
def record_problems(
files: dict[str, setup.ParsedFile], records: dict[str, setup.ParsedBmod], skills_dir: Path
) -> list[str]:
problems: list[str] = []
for name, parsed in files.items():
if name.startswith(RECORD_PREFIX) and parsed.bmod is None:
problems.append(
f"{rel(name)}: a {RECORD_PREFIX}* folder holds a module record, but this file has no [bmod]"
)
first_by_code: dict[str, str] = {}
for name, record in records.items():
problem = version_problem(record.version)
if problem is not None:
problems.append(f"{rel(name)}: [bmod] {problem}")
skill_md = skills_dir / name / "SKILL.md"
if skill_md.is_symlink() or not skill_md.is_file():
problems.append(f"skills/{name}: a module record folder must ship SKILL.md as a plain file")
if files[name].skill is None and name != RECORD_PREFIX + record.code:
problems.append(
f"{rel(name)}: a module record folder is named {RECORD_PREFIX + record.code!r} "
f"after its code; this one is {name!r}"
)
first = first_by_code.setdefault(record.code.casefold(), name)
if first != name:
problems.append(
f"{rel(name)}: module code {record.code!r} is already declared by {rel(first)}; one record per code"
)
versions = {name: record.version for name, record in records.items()}
if len(set(versions.values())) > 1:
listed = ", ".join(f"{name} has {version!r}" for name, version in versions.items())
problems.append(f"skills/: every module record carries one version, stamped together; {listed}")
return problems
def membership_problems(
files: dict[str, setup.ParsedFile],
records: dict[str, setup.ParsedBmod],
members: dict[str, tuple[str, ...]],
shipped: set[str],
) -> list[str]:
problems: list[str] = []
for name, parsed in files.items():
if parsed.skill is None:
continue
if parsed.bmod is not None:
if name not in members[name]:
problems.append(f"{rel(name)}: holds [skill], but its own [bmod] skills list leaves {name!r} out")
continue
bmod = parsed.skill.bmod
if bmod in records and parsed.skill.source != records[bmod].update_source:
problems.append(
f"{rel(name)}: [skill] source {parsed.skill.source!r} differs from {rel(bmod)} "
f"update_source {records[bmod].update_source!r}"
)
if bmod not in records:
problems.append(
f"{rel(name)}: [skill] bmod names {bmod!r}, which is not a module record in this repository"
)
elif name not in members[bmod]:
problems.append(f"{rel(name)}: [skill] bmod names {bmod!r}, but {rel(bmod)} does not list {name!r}")
for name in records:
for member in members[name]:
parsed = files.get(member)
if member not in shipped:
problems.append(f"{rel(name)}: lists the skill {member!r}, which this repository does not ship")
elif parsed is None:
continue
elif parsed.skill is None or (parsed.bmod is not None and member != name):
problems.append(
f"{rel(name)}: lists {member!r}, which is a module record and not a skill of this module"
)
elif member != name and parsed.skill.bmod != name:
problems.append(
f"{rel(name)}: lists the skill {member!r}, but {rel(member)} names {parsed.skill.bmod!r} as its bmod"
)
return problems
def retired_problems(records: dict[str, setup.ParsedRetired], shipped: set[str]) -> list[str]:
"""A retired name is never shipped again, and a rename points at a skill this repository ships."""
problems: list[str] = []
retired_by: dict[str, str] = {}
for name, record in records.items():
retired = [*(rename.old for rename in record.renamed), *record.removed]
for old in retired:
if old in shipped:
problems.append(
f"{retired_rel(name)}: retires {old!r}, but skills/{old} still ships; a retired name is never reused"
)
first = retired_by.setdefault(old, name)
if first != name:
problems.append(f"{retired_rel(name)}: retires {old!r}, which {retired_rel(first)} already retires")
targets = [rename.new for rename in record.renamed]
for new in dict.fromkeys(target for target in targets if targets.count(target) > 1):
problems.append(
f"{retired_rel(name)}: renames more than one skill to {new!r}; "
"their customizations would collide, so list the extras under removed"
)
for rename in record.renamed:
if rename.new not in shipped:
problems.append(
f"{retired_rel(name)}: renames {rename.old!r} to {rename.new!r}, which this repository does not ship"
)
return problems
def retired_rel(folder: str) -> str:
return f"skills/{folder}/{setup.RETIRED_NAME}"
def requirement_problems(
folder: str, table: str, source: setup.ParsedBmod | setup.ParsedSkill, shipped: set[str]
) -> list[str]:
"""Shape is the runtime parser's job. These are the rules only the repository can decide."""
problems: list[str] = []
for field in ("required_skills", "recommended_skills"):
for requirement in getattr(source, field):
where = f"{rel(folder)}: {table}.{field} entry {requirement.skill!r}"
# setup.py drops build metadata when ordering, so such a minimum could never be told apart.
if requirement.version is not None and "+" in requirement.version:
problems.append(
f"{where} version {requirement.version!r} carries build metadata, which setup.py ignores "
f"when ordering; it would compare equal to {requirement.version.split('+', 1)[0]!r}"
)
if requirement.source is None and requirement.skill not in shipped:
problems.append(f"{where} names no skill in this repository and gives no source to fetch it from")
return problems
def knowledge_problems(name: str, record: setup.ParsedBmod, folder: Path, members: tuple[str, ...]) -> list[str]:
problems: list[str] = []
help_path = folder / knowledge.HELP_NAME
if name.startswith("bmod-") or help_path.exists() or help_path.is_symlink():
problem = plain_file_problem(folder, PurePosixPath(knowledge.HELP_NAME))
if problem is not None:
problems.append(f"skills/{name}/{knowledge.HELP_NAME}, which every bmod-* folder holds, {problem}")
for entry in record.knowledge:
if entry.path.as_posix() == knowledge.HELP_NAME:
problems.append(f"{rel(name)}: knowledge names {knowledge.HELP_NAME!r}, which is always read")
continue
problem = plain_file_problem(folder, entry.path)
if problem is not None:
problems.append(f"{rel(name)}: knowledge names {entry.path.as_posix()!r}, which {problem}")
for skill in entry.skills or ():
if skill not in members:
problems.append(
f"{rel(name)}: knowledge {entry.path.as_posix()!r} names {skill!r}, which is not a skill of "
f"module {record.code!r}"
)
return problems
TOPIC_REFERENCE = re.compile(r"`help/([^`/<>]+\.md)`")
def topic_problems(name: str, folder: Path) -> list[str]:
"""A topic `help.md` never points to is never read, and a pointer to no file misleads the reader."""
try:
text = (folder / knowledge.HELP_NAME).read_text(encoding="utf-8")
except (OSError, UnicodeError):
text = ""
help_file = PurePosixPath(knowledge.HELP_NAME).name
named = set(TOPIC_REFERENCE.findall(text)) - {help_file}
shipped = {path.name for path in (folder / knowledge.TOPICS_DIR).glob("*.md")} - {help_file}
where = f"skills/{name}/{knowledge.TOPICS_DIR}"
problems = [f"{where}/{topic} is never named in {knowledge.HELP_NAME}" for topic in sorted(shipped - named)]
problems += [
f"skills/{name}/{knowledge.HELP_NAME} names {where}/{topic}, which does not exist"
for topic in sorted(named - shipped)
]
for topic in sorted(shipped):
try:
body = (folder / knowledge.TOPICS_DIR / topic).read_text(encoding="utf-8")
except (OSError, UnicodeError):
continue
problems += [
f"{where}/{topic} names {where}/{other}, which does not exist"
for other in sorted(set(TOPIC_REFERENCE.findall(body)) - shipped - {help_file})
]
for topic in sorted(shipped & named):
problem = plain_file_problem(folder, PurePosixPath(knowledge.TOPICS_DIR, topic))
if problem is not None:
problems.append(f"{where}/{topic} {problem}")
return problems
def plain_file_problem(folder: Path, relative: PurePosixPath) -> str | None:
path = folder.joinpath(*relative.parts)
if path.is_symlink():
return "is a symlink, not a plain file"
try:
knowledge.read_document(path, folder)
except FileNotFoundError:
return "the module record does not ship"
except (OSError, ValueError) as error:
return str(error)
return None
def roster_file_problems(name: str, record: setup.ParsedBmod, folder: Path, skills_dir: Path) -> list[str]:
path = folder / knowledge.ROSTER_NAME
if not path.exists() and not path.is_symlink():
return []
where = f"skills/{name}/{knowledge.ROSTER_NAME}"
problem = plain_file_problem(folder, PurePosixPath(knowledge.ROSTER_NAME))
if problem is not None:
return [f"{where} {problem}"]
try:
party = tomllib.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, tomllib.TOMLDecodeError) as error:
return [f"{where}: cannot read roster: {error}"]
return [f"{where}: {problem}" for problem in roster_problems(party, skills_dir)]
def roster_problems(party: dict, skills_dir: Path) -> list[str]:
"""A group naming a member nobody defines, or a member naming a skill this repo lacks, is a typo."""
problems: list[str] = []
members = [member for member in as_list(party.get("members")) if isinstance(member, dict)]
codes = [member.get("code") for member in members]
repeated = sorted({code for code in codes if isinstance(code, str) and codes.count(code) > 1})
problems += [f"member code {code!r} is defined twice" for code in repeated]
for member in members:
skill = member.get("skill")
if skill is not None and not (isinstance(skill, str) and (skills_dir / skill / "SKILL.md").is_file()):
problems.append(f"member {member.get('code')!r} names skill {skill!r}, which this repository does not ship")
for group in as_list(party.get("groups")):
if not isinstance(group, dict):
continue
for code in as_list(group.get("members")):
if code not in codes:
problems.append(f"group {group.get('id')!r} lists {code!r}, which no member defines")
return problems
def as_list(value: object) -> list:
return value if isinstance(value, list) else []
def runtime_problems(skills_dir: Path) -> list[str]:
"""The tree as `bmad` itself would discover it. Runs only on a tree the checks above accept."""
problems: list[str] = []
try:
installation = setup.discover_installation(skills_dir / "bmad")
except Exception as error:
return [f"skills/: setup.py cannot discover the modules: {error}"]
problems += [f"skills/: setup.py reports: {problem['message']}" for problem in installation.problems]
problems += [
f"skills/: setup.py finds no module record {missing['bmod']!r} for {missing['skill']!r}"
for missing in installation.missing_records
]
report = knowledge.collect([skills_dir])
problems += [f"skills/: knowledge.py reports: {problem['problem']}" for problem in report["problems"]]
return problems
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description="Check every skills/*/bmod.toml in a repository.")
parser.add_argument(
"--project-root", type=Path, default=Path.cwd(), help="repository to check (default: the current directory)"
)
args = parser.parse_args(argv)
report = check_repo(args.project_root.resolve())
if report.problems:
print(f"bmod file validation failed ({len(report.problems)}):", file=sys.stderr)
for problem in report.problems:
print(f" {problem}", file=sys.stderr)
return 1
print(
f"bmod files valid: {report.skills} skills, {len(report.records)} module records, "
f"{report.documents} knowledge documents."
)
return 0
if __name__ == "__main__":
if sys.platform == "win32":
# Piped output on Windows defaults to a legacy code page, not UTF-8.
sys.stdout.reconfigure(encoding="utf-8")
sys.stderr.reconfigure(encoding="utf-8")
sys.exit(main())