Add BMAD planning artifacts: brief, PRD, architecture, UX, spec, tickets

Includes the BMAD install config, the initiative ticket tree, the portal
context-resources scripts, and the .gitignore change that tracks BMAD files
(generated _bmad/render/ is ignored).

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Sk72AxLwQnYttF6VFSxQLt
This commit is contained in:
Ebbe Baß
2026-10-15 13:33:02 +02:00
co-authored by Claude Sonnet 5.5
parent 97b2a95062
commit 24de5878ed
60 changed files with 13647 additions and 6 deletions
+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