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,389 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Resolve the party-mode roster, lazily.
Merges the roster the installed skills offer (agents, guests and groups, read
by `_bmad/scripts/roster.py`) with the user's custom `party_members` and
`party_groups` into one collective, then projects only what the moment needs:
* default (no flag) — the active roster to load on entry: the
`default_party` group if one is configured, else the whole collective.
Other groups come back as names only, so nothing you aren't using is
loaded into the party.
* --list-groups — just id + name + size for every configured group. The
cheap menu for "which room?", with no member detail.
* --party <id> — full member detail for one chosen group, on demand
(e.g. when the user switches rooms). Unknown id returns the available
names instead of an error wall.
The merge is deterministic (a keyed union; a custom member whose code
matches an installed agent overrides it), so the orchestrator consumes a
resolved roster instead of re-deriving it every session.
Stdlib only (Python 3.11+ for tomllib). Shells out to the project's roster.py
and resolve_customization.py. An install whose `_bmad/scripts` predates
roster.py falls back to the `[agents]` table resolve_config.py returns, and a
missing customization resolver falls back to reading customize.toml directly.
resolve_party.py --project-root P --skill S
resolve_party.py --project-root P --skill S --list-groups
resolve_party.py --project-root P --skill S --party writers-room (alias: --group)
"""
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)
# 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_roster(project_root: Path, skill_root: Path):
"""(agents, guests, groups, resolved, problems) from the skills installed beside this one.
agents are {code: entry} for the default room; guests are roster members
that are not installed agents, available to groups only. problems are what
roster.py could not use, so a missing agent or room is explained.
"""
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", {}) or {}
guests = {code: m for code, m in (data.get("members", {}) or {}).items() if code not in agents}
problems = [p.get("problem", "") for p in data.get("problems", []) or [] if isinstance(p, dict)]
return agents, guests, data.get("groups", []) or [], True, [p for p in problems if p]
data = _run_json(
[sys.executable, str(scripts / "resolve_config.py"), "--project-root", str(project_root), "--key", "agents"]
)
if data is None:
return {}, {}, [], False, []
return data.get("agents", {}) or {}, {}, [], 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 or []:
if isinstance(g, dict) and g.get("id"):
merged[g["id"]] = g
return list(merged.values())
def load_workflow(project_root: Path, skill_root: Path):
"""Merged [workflow] table. Falls back to the skill's base customize.toml."""
script = project_root / "_bmad" / "scripts" / "resolve_customization.py"
data = _run_json(
[
sys.executable,
str(script),
"--skill",
str(skill_root),
"--project-root",
str(project_root),
"--key",
"workflow",
]
)
if data is not None and "workflow" in data:
return data["workflow"]
_warn(f"party customization override not applied, using the shipped party: {_last_error or 'no workflow table'}")
# Fallback: read the skill's base customize.toml directly (no override merge).
toml_path = skill_root / "customize.toml"
if toml_path.exists():
try:
with toml_path.open("rb") as f:
return tomllib.load(f).get("workflow", {})
except (OSError, tomllib.TOMLDecodeError):
pass
return {}
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"party member {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."""
if "-agent-" in code:
return code.split("-agent-", 1)[1]
for prefix in ("bmad-agent-", "bmad-"):
if code.startswith(prefix):
return code[len(prefix) :]
return code
def build_collective(agents: dict, party_members: list, guests: dict | None = None):
"""One pool keyed by code. Custom members override matching installed agents.
`guests` are roster members that are not installed agents: a module's extra
personas, or an agent whose skill is absent. They join the pool so a group
can seat them, and never the default room.
Returns (collective, index, installed_codes):
* collective — every member (installed + custom), the pool groups draw
from and the orchestrator can summon by name.
* index — maps every resolvable token (code, prefix-stripped alias,
lower-cased name) to a canonical code.
* installed_codes — the codes occupying an installed-agent slot, in
order. This is the DEFAULT room: installed agents (with any custom
override applied in place), and NOT the pure-custom additions. So
shipping or defining custom members grows the pool without crowding
the default party.
"""
collective = {}
index = {}
installed_codes = []
alias_owner = {}
def register(code, entry):
collective[code] = entry
index[code] = code
index[code.lower()] = code
# A short alias two codes claim resolves to neither; the full code still works.
alias = _alias(code).lower()
owner = alias_owner.setdefault(alias, code)
if owner == code:
index.setdefault(alias, code)
elif owner is not None:
alias_owner[alias] = None
if index.get(alias) == owner and alias != owner.lower():
del index[alias]
name = entry.get("name")
if name:
index[name.lower()] = code
for code, info in agents.items():
if _bad_member(code, info.get("name")):
continue
entry = {
"code": code,
"name": info.get("name", code),
"icon": info.get("icon", ""),
"title": info.get("title", ""),
"module": info.get("module", ""),
"source": "installed",
}
# Installs from before rosters recorded the persona as `description`.
persona = info.get("persona") or info.get("description")
if persona:
entry["persona"] = persona
for field in ("capabilities", "model"):
if info.get(field):
entry[field] = info[field]
register(code, entry)
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", "module", "skill", "install"):
if info.get(field):
entry[field] = info[field]
entry.setdefault("name", code)
if info.get("installed") is False:
entry["installed"] = False
register(code, entry)
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
# A custom member overrides an installed agent it matches by code/alias/name.
canonical = index.get(code) or index.get(code.lower()) or code
# Start from the installed entry so fields the override omits
# (icon, title, persona, module) survive.
entry = dict(collective.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)
# An override keeps the installed slot; a brand-new custom does not join it.
return collective, index, installed_codes
def resolve_members(member_tokens, collective, index):
"""(resolved entries in listed order, unresolved tokens)."""
resolved, unresolved = [], []
for token in member_tokens or []:
if not isinstance(token, str):
unresolved.append(token) # malformed config value — never a key lookup
continue
code = index.get(token) or index.get(token.lower())
if code and code in collective:
resolved.append(collective[code])
else:
unresolved.append(token)
return resolved, unresolved
def group_menu(groups):
"""Names only — the cheap menu. Open-cast groups (no roster) are flagged."""
out = []
for g in groups or []:
if not isinstance(g, dict) or not g.get("id"):
continue
members = g.get("members", []) or []
entry = {"id": g["id"], "name": g.get("name", g["id"]), "member_count": len(members)}
if not members:
entry["open_cast"] = True
out.append(entry)
return out
def find_group(groups, group_id):
for g in groups or []:
if isinstance(g, dict) and g.get("id") == group_id:
return g
return None
def group_detail(g, collective, index):
"""Full detail for one group: resolved members + the optional scene.
`scene` is a freeform line the orchestrator plays — setting, what's
happening, room dynamics, in-the-moment character notes. Surfaced only
here (when a group is the active/chosen roster), never in the menu.
`members` is optional. With none, the group is open-cast: `open_cast`
is flagged and the scene describes the pool the orchestrator casts from
on the fly (e.g. "figures from the Star Wars Rebels universe"). A few
listed members anchor the room; the scene can still invite more.
"""
raw_members = g.get("members", []) or []
members, unresolved = resolve_members(raw_members, collective, index)
detail = {
"active": g["id"],
"name": g.get("name", g["id"]),
"members": members,
"unresolved": unresolved,
"memory_enabled": bool(g.get("memory", False)),
}
if g.get("scene"):
detail["scene"] = g["scene"]
if not raw_members:
detail["open_cast"] = True
return detail
def build_parser():
ap = argparse.ArgumentParser(description="Resolve the party-mode roster, lazily.")
ap.add_argument("--project-root", required=True)
ap.add_argument("--skill", required=True, help="Path to the bmad-party-mode skill dir")
ap.add_argument("--party", "--group", dest="party", help="Resolve full detail for this group id")
ap.add_argument("--list-groups", action="store_true", help="Group names only")
return ap
def main():
args = build_parser().parse_args()
project_root = Path(args.project_root).resolve()
skill_root = Path(args.skill).resolve()
workflow = load_workflow(project_root, skill_root)
agents, guests, roster_groups, agents_ok, roster_problems = load_roster(project_root, skill_root)
groups = merge_groups(roster_groups, workflow.get("party_groups", []) or [])
default_party = workflow.get("default_party", "") or ""
party_mode = workflow.get("party_mode", "session") or "session"
# The global party_memory flag governs only the DEFAULT installed-agent room;
# a named group carries its own `memory` flag (resolved in group_detail).
party_memory = bool(workflow.get("party_memory", True))
if args.list_groups:
_emit(
{
"party_mode": party_mode,
"default_party": default_party,
"groups": group_menu(groups),
}
)
return
collective, index, installed_codes = build_collective(agents, workflow.get("party_members", []), guests)
if args.party:
g = find_group(groups, args.party)
if g is None:
_emit({"error": "unknown_group", "requested": args.party, "available": group_menu(groups)})
return
detail = {**group_detail(g, collective, index), "party_mode": party_mode}
if roster_problems:
detail["roster_problems"] = roster_problems
_emit(detail)
return
# Default: the active roster to load on entry.
result = {"party_mode": party_mode, "groups": group_menu(groups), "installed_agents_resolved": agents_ok}
if roster_problems:
result["roster_problems"] = roster_problems
g = find_group(groups, default_party) if default_party else None
if g is not None:
result.update(group_detail(g, collective, index))
else:
# No default group: the installed agents (custom additions stay in the
# pool but don't crowd the default room), exactly like a plain install.
result.update(
{"active": "installed", "members": [collective[c] for c in installed_codes], "memory_enabled": party_memory}
)
_emit(result)
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,309 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Unit tests for resolve_party.py — merge, alias, override, group 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_party 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")
def test_strips_a_module_prefix_before_agent(self):
self.assertEqual(rp._alias("bmad-cis-agent-storyteller"), "storyteller")
class TestBuildCollective(unittest.TestCase):
def test_installed_agents_indexed_by_code_alias_and_name(self):
col, idx, _ = rp.build_collective(AGENTS, [])
self.assertEqual(set(col), {"bmad-agent-analyst", "bmad-agent-pm"})
self.assertEqual(idx["analyst"], "bmad-agent-analyst") # alias
self.assertEqual(idx["mary"], "bmad-agent-analyst") # name (ci)
self.assertEqual(idx["bmad-agent-pm"], "bmad-agent-pm") # full code
self.assertEqual(col["bmad-agent-analyst"]["source"], "installed")
def test_custom_member_appends(self):
col, _, _ = rp.build_collective(AGENTS, [{"code": "morpheus", "name": "Morpheus", "persona": "riddles"}])
self.assertIn("morpheus", col)
self.assertEqual(col["morpheus"]["source"], "custom")
self.assertEqual(col["morpheus"]["persona"], "riddles")
def test_custom_overrides_installed_by_alias(self):
col, _, _ = rp.build_collective(AGENTS, [{"code": "analyst", "name": "Mary-Custom", "persona": "p"}])
# Override lands on the canonical installed code, not a new "analyst" entry.
self.assertNotIn("analyst", col)
self.assertEqual(col["bmad-agent-analyst"]["source"], "custom")
self.assertEqual(col["bmad-agent-analyst"]["name"], "Mary-Custom")
def test_member_without_code_skipped(self):
col, _, _ = rp.build_collective(AGENTS, [{"name": "Nameless"}])
self.assertEqual(set(col), {"bmad-agent-analyst", "bmad-agent-pm"})
class TestResolveMembers(unittest.TestCase):
def setUp(self):
self.col, self.idx, _ = rp.build_collective(AGENTS, [{"code": "morpheus", "name": "Morpheus"}])
def test_resolves_in_listed_order_and_flags_unknowns(self):
resolved, unresolved = rp.resolve_members(["morpheus", "analyst", "ghost"], self.col, self.idx)
self.assertEqual([m["code"] for m in resolved], ["morpheus", "bmad-agent-analyst"])
self.assertEqual(unresolved, ["ghost"])
def test_empty(self):
self.assertEqual(rp.resolve_members([], self.col, self.idx), ([], []))
class TestGroups(unittest.TestCase):
GROUPS = [
{"id": "wr", "name": "Writers", "members": ["analyst", "morpheus"]},
{"id": "bad"}, # no name -> falls back to id; no members -> count 0
{"name": "no-id"}, # dropped from menu
]
def test_menu_is_names_only_with_counts_and_open_cast_flag(self):
menu = rp.group_menu(self.GROUPS)
self.assertEqual(
menu,
[
{"id": "wr", "name": "Writers", "member_count": 2},
{"id": "bad", "name": "bad", "member_count": 0, "open_cast": True},
],
)
def test_find_group(self):
self.assertEqual(rp.find_group(self.GROUPS, "wr")["name"], "Writers")
self.assertIsNone(rp.find_group(self.GROUPS, "missing"))
class TestGroupDetail(unittest.TestCase):
def setUp(self):
self.col, self.idx, _ = rp.build_collective(AGENTS, [{"code": "morpheus", "name": "Morpheus"}])
def test_scene_passes_through_when_present(self):
g = {
"id": "tos-10-forward",
"name": "Ten Forward",
"members": ["morpheus"],
"scene": "Late evening, a few rounds in.",
}
d = rp.group_detail(g, self.col, self.idx)
self.assertEqual(d["scene"], "Late evening, a few rounds in.")
self.assertEqual([m["code"] for m in d["members"]], ["morpheus"])
def test_scene_omitted_when_absent_or_empty(self):
for g in ({"id": "g", "members": ["morpheus"]}, {"id": "g", "members": ["morpheus"], "scene": ""}):
self.assertNotIn("scene", rp.group_detail(g, self.col, self.idx))
def test_anchored_group_is_not_open_cast(self):
g = {"id": "g", "members": ["morpheus"]}
self.assertNotIn("open_cast", rp.group_detail(g, self.col, self.idx))
def test_open_cast_group_flagged_with_empty_members(self):
g = {
"id": "rebels",
"name": "Star Wars Rebels",
"scene": "Figures from the Rebels universe drop in as the topic calls for them.",
}
d = rp.group_detail(g, self.col, self.idx)
self.assertTrue(d["open_cast"])
self.assertEqual(d["members"], [])
self.assertEqual(d["scene"][:7], "Figures")
def test_memory_enabled_follows_group_flag_and_defaults_off(self):
on = rp.group_detail({"id": "g", "members": ["morpheus"], "memory": True}, self.col, self.idx)
self.assertTrue(on["memory_enabled"])
off = rp.group_detail({"id": "g", "members": ["morpheus"], "memory": False}, self.col, self.idx)
self.assertFalse(off["memory_enabled"])
absent = rp.group_detail({"id": "g", "members": ["morpheus"]}, self.col, self.idx)
self.assertFalse(absent["memory_enabled"]) # opt-in per named group
class TestInstalledCodesIsDefaultRoom(unittest.TestCase):
"""The default room is installed agents only; pure customs stay in the pool."""
def test_pure_custom_excluded_override_kept_in_default_room(self):
col, _, installed = rp.build_collective(
AGENTS,
[
{"code": "morpheus", "name": "Morpheus"}, # pure custom
{"code": "analyst", "name": "Mary-Custom", "persona": "p"}, # override
{"code": "sec-hawk", "name": "Vex"}, # shipped crew member
],
)
# Pure customs are in the pool...
self.assertIn("morpheus", col)
self.assertIn("sec-hawk", col)
# ...but NOT in the default room.
self.assertEqual(installed, ["bmad-agent-analyst", "bmad-agent-pm"])
default_room = [col[c]["code"] for c in installed]
self.assertEqual(default_room, ["bmad-agent-analyst", "bmad-agent-pm"])
# An override keeps its installed slot (and its custom content).
self.assertEqual(col["bmad-agent-analyst"]["name"], "Mary-Custom")
class TestRoster(unittest.TestCase):
GUESTS = {
"pip": {"name": "Pip", "persona": "Asks why."},
"bmad-agent-dev": {"name": "Amelia", "persona": "Exact.", "skill": "bmad-agent-dev", "installed": False},
}
def test_guests_join_the_pool_and_never_the_default_room(self):
col, idx, installed = rp.build_collective(AGENTS, [], self.GUESTS)
self.assertEqual(installed, ["bmad-agent-analyst", "bmad-agent-pm"])
self.assertEqual(col["pip"]["source"], "roster")
self.assertEqual(idx["amelia"], "bmad-agent-dev")
self.assertIs(col["bmad-agent-dev"]["installed"], False)
def test_a_group_can_seat_a_guest_and_an_agent_whose_skill_is_absent(self):
col, idx, _ = rp.build_collective(AGENTS, [], self.GUESTS)
detail = rp.group_detail({"id": "room", "members": ["analyst", "pip", "dev"]}, col, idx)
self.assertEqual([m["name"] for m in detail["members"]], ["Mary", "Pip", "Amelia"])
self.assertEqual(detail["unresolved"], [])
def test_a_short_alias_two_codes_claim_resolves_to_neither(self):
agents = {"bmad-agent-dev": {"name": "Amelia"}, "bmad-cis-agent-dev": {"name": "Devi"}}
col, idx, _ = rp.build_collective(agents, [])
members, unresolved = rp.resolve_members(["dev", "bmad-cis-agent-dev", "amelia"], col, idx)
self.assertEqual([m["name"] for m in members], ["Devi", "Amelia"])
self.assertEqual(unresolved, ["dev"])
def test_what_the_roster_could_not_use_is_passed_on(self):
absent = "module record bmod-demo is not installed; it is named by demo-one; install it with `npx skills add acme/tools --skill bmod-demo`"
report = {
"agents": {},
"problems": [
{"kind": "member", "problem": "demo: member 'x' is already defined by other"},
{
"kind": "module",
"bmod": "bmod-demo",
"install": "npx skills add acme/tools --skill bmod-demo",
"problem": absent,
},
],
}
original = rp._run_json
rp._run_json = lambda cmd: report
try:
problems = rp.load_roster(Path("project"), Path("skill"))[4]
finally:
rp._run_json = original
self.assertEqual(problems, ["demo: member 'x' is already defined by other", absent])
def test_a_custom_group_replaces_a_roster_group_with_its_id(self):
groups = rp.merge_groups(
[{"id": "room", "name": "Shipped"}, {"id": "other", "name": "Other"}], [{"id": "room", "name": "Mine"}]
)
self.assertEqual([(g["id"], g["name"]) for g in groups], [("room", "Mine"), ("other", "Other")])
def test_an_install_from_before_rosters_keeps_its_description_as_the_persona(self):
col, _, _ = rp.build_collective({"bmad-agent-pm": {"name": "John", "description": "Asks why."}}, [])
self.assertEqual(col["bmad-agent-pm"]["persona"], "Asks why.")
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_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 TestArguments(unittest.TestCase):
def test_group_is_an_alias_of_party(self):
for flag in ("--party", "--group"):
args = rp.build_parser().parse_args(["--project-root", "p", "--skill", "s", flag, "writers-room"])
self.assertEqual(args.party, "writers-room")
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_collective(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("member 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_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()