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
@@ -0,0 +1,365 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Resolve the personas and parties the forge can bring into the room.
The forge cross-examines witnesses: the installed BMAD agents, plus any
custom personas and party groups the user has authored for `bmad-party-mode`.
This surfaces all of them in one shot so the orchestrator never has to ask
"who's available?" — it just intermixes whoever fits the branch, alongside
any persona the user names on the fly.
What it returns (JSON, stdout):
* agents — the installed BMAD roster: the default room, always present.
* members — extra personas in the pool: guests an installed module's
roster offers, and party_members the user defined that
aren't already an installed slot.
* parties — the named party groups, an installed module's first and then
the user's, members resolved to brief entries; open-cast
groups (scene names a pool, no roster) are flagged.
* default_party — the group id pinned as party-mode's default, if any.
Discovery is best-effort and never blocks the forge. The installed roster
comes from `_bmad/scripts/roster.py`, the same source `bmad-party-mode` uses,
so both skills see the same room; the `[agents]` table of the central config
is the fallback for a project set up before rosters existed. Custom personas/parties come from
`bmad-party-mode`'s resolved customization when that skill is found beside
this one, else from the user's override TOMLs read directly. Anything that
can't be resolved is simply omitted and flagged, never fatal.
Stdlib only (Python 3.11+ for tomllib).
resolve_personas.py --project-root P --skill S
"""
import argparse
import json
import subprocess
import sys
from pathlib import Path
try:
import tomllib
except ImportError: # pragma: no cover - guarded for <3.11
sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib`).\n")
sys.exit(3)
PARTY_SKILL = "bmad-party-mode"
# Why the last _run_json call failed; tests stub _run_json with one argument.
_last_error = ""
def _run_json(cmd):
"""Run a resolver script and parse its JSON stdout. None on any failure."""
global _last_error
_last_error = ""
try:
out = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", timeout=60)
except (OSError, subprocess.SubprocessError) as exc:
_last_error = str(exc)
return None
if out.returncode != 0 or not out.stdout.strip():
_last_error = out.stderr.strip() or f"exit {out.returncode}, no output"
return None
try:
return json.loads(out.stdout)
except json.JSONDecodeError as exc:
_last_error = f"resolver printed invalid JSON: {exc}"
return None
def _load_toml(path: Path):
if not path.exists():
return {}
try:
with path.open("rb") as f:
data = tomllib.load(f)
return data if isinstance(data, dict) else {}
except (OSError, tomllib.TOMLDecodeError):
return {}
def load_roster(project_root: Path, skill_root: Path):
"""(agents, guests, groups, resolved_ok) from the skills installed beside this one.
agents are {code: entry} for the default room. guests are the other
roster members, available by name or through a group, as in party mode.
"""
scripts = project_root / "_bmad" / "scripts"
data = _run_json(
[sys.executable, str(scripts / "roster.py"), "--skill", str(skill_root), "--project-root", str(project_root)]
)
if data is not None:
agents = data.get("agents", {})
members = data.get("members", {})
groups = data.get("groups", [])
agents = agents if isinstance(agents, dict) else {}
guests = {
code: member
for code, member in (members if isinstance(members, dict) else {}).items()
if code not in agents and isinstance(member, dict)
}
return agents, guests, groups if isinstance(groups, list) else [], True
agents, resolved = load_config_agents(project_root)
return agents, {}, [], resolved
def load_config_agents(project_root: Path):
"""The central config's [agents] table as {code: entry}. (dict, resolved_ok).
The core resolver may emit agents as a dict keyed by code or as an array
of tables (depending on how the layers merged); normalize both to a dict.
"""
script = project_root / "_bmad" / "scripts" / "resolve_config.py"
data = _run_json([sys.executable, str(script), "--project-root", str(project_root), "--key", "agents"])
if data is None:
return {}, False
agents = data.get("agents", {}) or {}
if isinstance(agents, list):
agents = {a["code"]: a for a in agents if isinstance(a, dict) and a.get("code")}
elif not isinstance(agents, dict):
agents = {}
return agents, True
def merge_groups(roster_groups: list, custom_groups: list) -> list:
"""Roster groups first, then the user's; a custom group replaces a roster group with its id."""
merged = {g["id"]: g for g in roster_groups if isinstance(g, dict) and g.get("id")}
for g in custom_groups if isinstance(custom_groups, list) else []:
if isinstance(g, dict) and g.get("id"):
merged[g["id"]] = g
return list(merged.values())
def find_party_skill(project_root: Path, skill_root: Path):
"""Locate the installed bmad-party-mode skill dir, or None.
Skills install as siblings, so the party skill is almost always next to
this one. A couple of common install roots cover the rest.
"""
candidates = [
skill_root.parent / PARTY_SKILL,
project_root / ".claude" / "skills" / PARTY_SKILL,
project_root / "_bmad" / "skills" / PARTY_SKILL,
]
for c in candidates:
if (c / "customize.toml").exists():
return c
return None
def load_party_workflow(project_root: Path, party_skill: Path):
"""Merged [workflow] table for bmad-party-mode (base + user overrides)."""
resolver = project_root / "_bmad" / "scripts" / "resolve_customization.py"
data = _run_json(
[
sys.executable,
str(resolver),
"--skill",
str(party_skill),
"--project-root",
str(project_root),
"--key",
"workflow",
]
)
if data is not None and isinstance(data.get("workflow"), dict):
return data["workflow"]
_warn(f"party customization override not applied, using the shipped party: {_last_error or 'no workflow table'}")
# Fallback: base customize.toml directly, no override merge.
wf = _load_toml(party_skill / "customize.toml").get("workflow", {})
return wf if isinstance(wf, dict) else {}
def load_party_overrides(project_root: Path):
"""Custom personas/parties when party-mode itself isn't installed.
Reads only the user's override TOMLs (team then personal, personal wins on
scalars). No base roster exists in this path, so a shallow merge is enough.
"""
custom = project_root / "_bmad" / "custom"
team = _load_toml(custom / f"{PARTY_SKILL}.toml").get("workflow", {})
user = _load_toml(custom / f"{PARTY_SKILL}.user.toml").get("workflow", {})
team = team if isinstance(team, dict) else {}
user = user if isinstance(user, dict) else {}
merged = dict(team)
for key, val in user.items():
if isinstance(val, list) and isinstance(merged.get(key), list):
merged[key] = merged[key] + val
else:
merged[key] = val
return merged
def _warn(message: str):
sys.stderr.write(f"warning: {message}\n")
def _bad_member(code, name) -> bool:
"""True, with a warning, when a member's code or name is not a string."""
if isinstance(code, str) and (name is None or isinstance(name, str)):
return False
_warn(f"persona {code!r} left out: code and name must be strings")
return True
def _alias(code: str) -> str:
"""Short alias for an installed agent code: bmad-agent-analyst -> analyst."""
for prefix in ("bmad-agent-", "bmad-"):
if code.startswith(prefix):
return code[len(prefix) :]
return code
def build_pool(agents: dict, party_members: list, guests: dict | None = None):
"""One pool keyed by code; custom members override matching installed slots.
Returns (pool, index, installed_codes, custom_codes):
* installed_codes — the default room (installed agents, overrides applied
in place); custom-only additions stay in the pool but don't crowd it.
* custom_codes — pure-custom personas (no installed slot), the extra
faces the forge can summon by name or via a party group.
"""
pool, index, installed_codes, custom_codes = {}, {}, [], []
def register(code, entry):
pool[code] = entry
index[code] = code
index[code.lower()] = code
index[_alias(code).lower()] = code
name = entry.get("name")
if name:
key = name.lower()
# A custom rename must not hijack another agent's name lookup.
if index.get(key, code) == code:
index[key] = code
for code, info in (agents or {}).items():
if _bad_member(code, info.get("name")):
continue
register(
code,
{
"code": code,
"name": info.get("name", code),
"icon": info.get("icon", ""),
"title": info.get("title", ""),
"description": info.get("description", ""),
"persona": info.get("persona", ""),
"source": "installed",
},
)
installed_codes.append(code)
for code, info in (guests or {}).items():
if _bad_member(code, info.get("name")):
continue
entry = {"code": code, "source": "roster"}
for field in ("name", "icon", "title", "persona", "capabilities", "model"):
if info.get(field):
entry[field] = info[field]
entry.setdefault("name", code)
register(code, entry)
custom_codes.append(code)
for m in party_members if isinstance(party_members, list) else []:
if not isinstance(m, dict):
continue
code = m.get("code")
if code is None or code == "" or _bad_member(code, m.get("name")):
continue
canonical = index.get(code) or index.get(code.lower()) or code
was_installed = canonical in pool
# Start from the installed entry so fields the override omits
# (icon, title, description) survive.
entry = dict(pool.get(canonical, {}))
entry.update({"code": canonical, "source": "custom"})
for field in ("name", "icon", "title", "persona", "capabilities", "model"):
if m.get(field) is not None:
entry[field] = m[field]
entry.setdefault("name", canonical)
register(canonical, entry)
if not was_installed:
custom_codes.append(canonical)
return pool, index, installed_codes, custom_codes
def _brief(entry):
"""The slim card the orchestrator needs to cast a persona."""
out = {k: entry[k] for k in ("code", "name", "icon", "title", "source") if entry.get(k)}
for k in ("description", "persona", "capabilities", "model"):
if entry.get(k):
out[k] = entry[k]
return out
def resolve_parties(groups, pool, index):
out = []
for g in groups or []:
if not isinstance(g, dict) or not g.get("id"):
continue
raw = g.get("members", []) or []
members = []
for t in raw:
key = t if isinstance(t, str) else str(t)
code = index.get(key) or index.get(key.lower())
if code in pool:
members.append(_brief(pool[code]))
party = {"id": g["id"], "name": g.get("name", g["id"]), "members": members}
if g.get("scene"):
party["scene"] = g["scene"]
if not raw:
party["open_cast"] = True
out.append(party)
return out
def main():
ap = argparse.ArgumentParser(description="Resolve forge personas and parties.")
ap.add_argument("--project-root", required=True)
ap.add_argument("--skill", required=True, help="Path to the bmad-forge-idea skill dir")
args = ap.parse_args()
project_root = Path(args.project_root).resolve()
skill_root = Path(args.skill).resolve()
agents, guests, roster_groups, agents_ok = load_roster(project_root, skill_root)
party_skill = find_party_skill(project_root, skill_root)
if party_skill is not None:
workflow = load_party_workflow(project_root, party_skill)
else:
workflow = load_party_overrides(project_root)
pool, index, installed_codes, custom_codes = build_pool(agents, workflow.get("party_members", []), guests)
parties = resolve_parties(merge_groups(roster_groups, workflow.get("party_groups", [])), pool, index)
_emit(
{
"agents": [_brief(pool[c]) for c in installed_codes],
"members": [_brief(pool[c]) for c in custom_codes],
"parties": parties,
"default_party": workflow.get("default_party", "") or "",
"party_mode_found": party_skill is not None,
"agents_resolved": agents_ok,
}
)
def _emit(obj):
reconfigure = getattr(sys.stdout, "reconfigure", None)
if reconfigure is not None:
reconfigure(encoding="utf-8")
sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
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")
main()
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Unit tests for resolve_personas.py — pool merge, alias, party resolution."""
import contextlib
import io
import sys
import tempfile
import unittest
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import resolve_personas as rp # noqa: E402
AGENTS = {
"bmad-agent-analyst": {"name": "Mary", "icon": "📊", "title": "Analyst"},
"bmad-agent-pm": {"name": "John", "icon": "📋", "title": "PM"},
}
class TestAlias(unittest.TestCase):
def test_strips_known_prefixes(self):
self.assertEqual(rp._alias("bmad-agent-analyst"), "analyst")
self.assertEqual(rp._alias("bmad-foo"), "foo")
def test_passes_through_unprefixed(self):
self.assertEqual(rp._alias("morpheus"), "morpheus")
class TestBuildPool(unittest.TestCase):
def test_installed_become_default_room_indexed_every_way(self):
pool, idx, installed, custom = rp.build_pool(AGENTS, [])
self.assertEqual(installed, ["bmad-agent-analyst", "bmad-agent-pm"])
self.assertEqual(custom, [])
self.assertEqual(idx["analyst"], "bmad-agent-analyst") # alias
self.assertEqual(idx["mary"], "bmad-agent-analyst") # name (ci)
self.assertEqual(pool["bmad-agent-analyst"]["source"], "installed")
def test_pure_custom_member_stays_out_of_default_room(self):
pool, _, installed, custom = rp.build_pool(
AGENTS, [{"code": "morpheus", "name": "Morpheus", "persona": "riddles"}]
)
self.assertEqual(custom, ["morpheus"])
self.assertNotIn("morpheus", installed)
self.assertEqual(pool["morpheus"]["persona"], "riddles")
def test_custom_override_lands_on_installed_slot_not_a_new_face(self):
pool, _, installed, custom = rp.build_pool(AGENTS, [{"code": "analyst", "name": "Mary-Custom", "persona": "p"}])
self.assertNotIn("analyst", pool)
self.assertEqual(custom, []) # an override is not a new face
self.assertEqual(pool["bmad-agent-analyst"]["source"], "custom")
self.assertEqual(pool["bmad-agent-analyst"]["name"], "Mary-Custom")
def test_member_without_code_skipped(self):
pool, _, _, custom = rp.build_pool(AGENTS, [{"name": "Nameless"}])
self.assertEqual(custom, [])
self.assertEqual(set(pool), {"bmad-agent-analyst", "bmad-agent-pm"})
def test_custom_rename_does_not_hijack_another_agents_name(self):
# Override the analyst slot, renaming it to "John" — the PM's name.
# The PM's name lookup must survive (last-writer-wins would corrupt it).
_, idx, _, _ = rp.build_pool(AGENTS, [{"code": "analyst", "name": "John"}])
self.assertEqual(idx["john"], "bmad-agent-pm")
def test_brief_carries_model_and_capabilities(self):
pool, _, _, _ = rp.build_pool(AGENTS, [{"code": "neo", "name": "Neo", "model": "opus", "capabilities": ["x"]}])
brief = rp._brief(pool["neo"])
self.assertEqual(brief["model"], "opus")
self.assertEqual(brief["capabilities"], ["x"])
def test_non_list_party_members_is_safe(self):
pool, _, installed, custom = rp.build_pool(AGENTS, "not-a-list")
self.assertEqual(custom, [])
self.assertEqual(set(pool), {"bmad-agent-analyst", "bmad-agent-pm"})
class TestResolveParties(unittest.TestCase):
def setUp(self):
self.pool, self.idx, _, _ = rp.build_pool(AGENTS, [{"code": "shark", "name": "Marcus", "title": "CFO"}])
def test_resolves_members_by_alias_and_custom_code(self):
parties = rp.resolve_parties(
[{"id": "tank", "name": "Tank", "scene": "hostile", "members": ["shark", "analyst"]}], self.pool, self.idx
)
self.assertEqual(len(parties), 1)
self.assertEqual([m["name"] for m in parties[0]["members"]], ["Marcus", "Mary"])
self.assertEqual(parties[0]["scene"], "hostile")
def test_unknown_member_dropped_silently(self):
parties = rp.resolve_parties([{"id": "g", "members": ["analyst", "ghost"]}], self.pool, self.idx)
self.assertEqual([m["name"] for m in parties[0]["members"]], ["Mary"])
def test_member_resolution_is_case_insensitive(self):
# A TOML author naturally writes "Analyst"/"Shark"; the filter accepts
# them via the lowercase index, so resolution must too (no KeyError).
parties = rp.resolve_parties([{"id": "g", "members": ["Analyst", "Shark"]}], self.pool, self.idx)
self.assertEqual([m["name"] for m in parties[0]["members"]], ["Mary", "Marcus"])
def test_non_string_member_does_not_crash(self):
# Malformed members (int, list) must drop silently, never raise.
parties = rp.resolve_parties([{"id": "g", "members": [123, ["x"], "analyst"]}], self.pool, self.idx)
self.assertEqual([m["name"] for m in parties[0]["members"]], ["Mary"])
def test_open_cast_group_flagged(self):
parties = rp.resolve_parties([{"id": "rebels", "name": "Rebels", "scene": "the Ghost"}], self.pool, self.idx)
self.assertTrue(parties[0]["open_cast"])
self.assertEqual(parties[0]["members"], [])
def test_group_without_id_skipped(self):
self.assertEqual(rp.resolve_parties([{"name": "no id"}], self.pool, self.idx), [])
class TestOverrideMergeFallback(unittest.TestCase):
"""When party-mode isn't installed, user override TOMLs are read directly."""
def test_arrays_append_scalars_override(self):
import tempfile
with tempfile.TemporaryDirectory() as d:
custom = Path(d) / "_bmad" / "custom"
custom.mkdir(parents=True)
(custom / "bmad-party-mode.toml").write_text(
'[workflow]\ndefault_party = "a"\n[[workflow.party_members]]\ncode = "x"\nname = "X"\n'
)
(custom / "bmad-party-mode.user.toml").write_text(
'[workflow]\ndefault_party = "b"\n[[workflow.party_members]]\ncode = "y"\nname = "Y"\n'
)
wf = rp.load_party_overrides(Path(d))
self.assertEqual(wf["default_party"], "b") # personal wins
self.assertEqual([m["code"] for m in wf["party_members"]], ["x", "y"]) # appended
class TestResolverInvocation(unittest.TestCase):
"""The wrapper knows the project root, so it must not let the resolver
infer one from the working directory (#2796)."""
def _captured_command(self, tmp):
captured = []
original = rp._run_json
rp._run_json = lambda cmd: captured.append(cmd) or {"workflow": {}}
try:
rp.load_party_workflow(Path(tmp) / "project", Path(tmp) / "skill")
finally:
rp._run_json = original
return captured[0]
def test_passes_project_root_to_the_customization_resolver(self):
with tempfile.TemporaryDirectory() as tmp:
cmd = self._captured_command(tmp)
self.assertIn("--project-root", cmd)
self.assertEqual(cmd[cmd.index("--project-root") + 1], str(Path(tmp) / "project"))
class TestLoadRoster(unittest.TestCase):
def _load(self, replies):
calls = []
original = rp._run_json
def fake(cmd):
calls.append(Path(cmd[1]).name)
return replies.get(Path(cmd[1]).name)
rp._run_json = fake
try:
return rp.load_roster(Path("/project"), Path("/skills/bmad-forge-idea")), calls
finally:
rp._run_json = original
def test_reads_agents_guests_and_groups_from_the_roster(self):
roster = {
"agents": {"bmad-agent-pm": {"name": "John"}},
"members": {
"bmad-agent-pm": {"name": "John", "skill": "bmad-agent-pm", "installed": True},
"skeptic": {"name": "The Skeptic", "persona": "Doubts everything."},
},
"groups": [{"id": "product-team", "members": ["bmad-agent-pm", "skeptic"]}],
}
(agents, guests, groups, resolved), calls = self._load({"roster.py": roster})
self.assertEqual((sorted(agents), sorted(guests), resolved), (["bmad-agent-pm"], ["skeptic"], True))
self.assertEqual(groups[0]["id"], "product-team")
self.assertEqual(calls, ["roster.py"])
def test_falls_back_to_the_config_agents_when_the_roster_script_is_absent(self):
(agents, guests, groups, resolved), calls = self._load({"resolve_config.py": {"agents": AGENTS}})
self.assertEqual((sorted(agents), guests, groups, resolved), (sorted(AGENTS), {}, [], True))
self.assertEqual(calls, ["roster.py", "resolve_config.py"])
def test_nothing_resolves_when_both_scripts_fail(self):
(agents, _, _, resolved), _ = self._load({})
self.assertEqual((agents, resolved), ({}, False))
class TestRosterInThePool(unittest.TestCase):
def test_a_guest_joins_the_pool_but_not_the_default_room_and_a_roster_group_resolves(self):
guests = {"skeptic": {"name": "The Skeptic", "persona": "Doubts everything."}}
pool, index, installed, extra = rp.build_pool(AGENTS, [], guests)
self.assertEqual((installed, extra), (list(AGENTS), ["skeptic"]))
self.assertEqual(pool["skeptic"]["source"], "roster")
groups = rp.merge_groups([{"id": "team", "members": ["pm", "skeptic"]}], [])
(party,) = rp.resolve_parties(groups, pool, index)
self.assertEqual([member["code"] for member in party["members"]], ["bmad-agent-pm", "skeptic"])
def test_a_custom_group_replaces_a_roster_group_with_its_id(self):
merged = rp.merge_groups([{"id": "team", "name": "Shipped"}], [{"id": "team", "name": "Mine"}, {"id": "x"}])
self.assertEqual([(group["id"], group.get("name")) for group in merged], [("team", "Mine"), ("x", None)])
class TestMalformedMembers(unittest.TestCase):
"""A member whose code or name is not a string is left out with a warning; the rest load."""
def _build(self, agents, members, guests=None):
err = io.StringIO()
with contextlib.redirect_stderr(err):
col, _, installed, _ = rp.build_pool(agents, members, guests)
return col, installed, err.getvalue()
def test_custom_member_with_non_string_code_or_name_is_left_out(self):
members = [
{"code": 7, "name": "Seven"},
{"code": 0, "name": "Zero"},
{"code": "neo", "name": ["Neo"]},
{"code": "trin", "name": "Trinity"},
]
col, _, err = self._build(AGENTS, members)
self.assertIn("trin", col)
self.assertNotIn("neo", col)
self.assertNotIn(7, col)
self.assertIn("7", err)
self.assertNotIn(0, col)
self.assertIn("persona 0 left out", err)
self.assertIn("'neo'", err)
def test_guest_with_non_string_name_is_left_out(self):
col, _, err = self._build(AGENTS, [], {"pip": {"name": 3}, "kit": {"name": "Kit"}})
self.assertNotIn("pip", col)
self.assertIn("kit", col)
self.assertIn("'pip'", err)
def test_installed_agent_with_non_string_name_is_left_out(self):
agents = {**AGENTS, "bmad-agent-odd": {"name": 42}}
col, installed, err = self._build(agents, [])
self.assertEqual(installed, ["bmad-agent-analyst", "bmad-agent-pm"])
self.assertNotIn("bmad-agent-odd", col)
self.assertIn("'bmad-agent-odd'", err)
class TestRejectedOverride(unittest.TestCase):
def test_a_rejected_override_warns_with_the_resolver_message_and_uses_the_shipped_party(self):
with tempfile.TemporaryDirectory() as tmp:
project, skill = Path(tmp) / "project", Path(tmp) / "skill"
scripts = project / "_bmad" / "scripts"
scripts.mkdir(parents=True)
(scripts / "resolve_customization.py").write_text(
"import sys\nsys.stderr.write('party_members[0].code must be a string')\nsys.exit(1)\n"
)
skill.mkdir()
(skill / "customize.toml").write_text('[workflow]\ndefault_party = "shipped"\n')
err = io.StringIO()
with contextlib.redirect_stderr(err):
wf = rp.load_party_workflow(project, skill)
self.assertEqual(wf, {"default_party": "shipped"})
self.assertIn("not applied", err.getvalue())
self.assertIn("party_members[0].code must be a string", err.getvalue())
if __name__ == "__main__":
unittest.main()