Some checks failed
CI / check (push) Has been cancelled
Capture uncommitted solver robustness work (regularization, domain errors, linear solver lifecycle, tube DP/MSH), web workbench updates, and synced BMAD skills across IDE agent folders before starting BPHX pressure-drop. Co-authored-by: Cursor <cursoragent@cursor.com>
273 lines
10 KiB
Python
273 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
# /// script
|
|
# requires-python = ">=3.11"
|
|
# ///
|
|
"""Resolve the party-mode roster, lazily.
|
|
|
|
Merges the installed BMAD agents with the user's custom `party_members`
|
|
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
|
|
resolve_config.py and resolve_customization.py; falls back to reading
|
|
customize.toml directly if the customization resolver is unavailable.
|
|
|
|
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
|
|
"""
|
|
|
|
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)
|
|
|
|
|
|
def _run_json(cmd):
|
|
"""Run a resolver script and parse its JSON stdout. None on any failure."""
|
|
try:
|
|
out = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
|
|
except (OSError, subprocess.SubprocessError):
|
|
return None
|
|
if out.returncode != 0 or not out.stdout.strip():
|
|
return None
|
|
try:
|
|
return json.loads(out.stdout)
|
|
except json.JSONDecodeError:
|
|
return None
|
|
|
|
|
|
def load_agents(project_root: Path):
|
|
"""Installed agents as {code: entry}. Empty dict (with a flag) on failure."""
|
|
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
|
|
return data.get("agents", {}) or {}, True
|
|
|
|
|
|
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), "--key", "workflow"])
|
|
if data is not None and "workflow" in data:
|
|
return data["workflow"]
|
|
# 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 _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_collective(agents: dict, party_members: list):
|
|
"""One pool keyed by code. Custom members override matching installed agents.
|
|
|
|
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 = []
|
|
|
|
def register(code, entry):
|
|
collective[code] = entry
|
|
index[code] = code
|
|
index[code.lower()] = code
|
|
index[_alias(code).lower()] = code
|
|
name = entry.get("name")
|
|
if name:
|
|
index[name.lower()] = code
|
|
|
|
for code, info in agents.items():
|
|
register(code, {
|
|
"code": code,
|
|
"name": info.get("name", code),
|
|
"icon": info.get("icon", ""),
|
|
"title": info.get("title", ""),
|
|
"description": info.get("description", ""),
|
|
"module": info.get("module", ""),
|
|
"team": info.get("team", ""),
|
|
"source": "installed",
|
|
})
|
|
installed_codes.append(code)
|
|
|
|
for m in party_members or []:
|
|
code = m.get("code")
|
|
if not code:
|
|
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
|
|
entry = {"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 []:
|
|
code = index.get(token) or index.get(str(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 main():
|
|
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", help="Resolve full detail for this group id")
|
|
ap.add_argument("--list-groups", action="store_true", help="Group names only")
|
|
args = ap.parse_args()
|
|
|
|
project_root = Path(args.project_root).resolve()
|
|
skill_root = Path(args.skill).resolve()
|
|
|
|
workflow = load_workflow(project_root, skill_root)
|
|
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))
|
|
|
|
# Group menu never needs the (more expensive) installed-agent resolve.
|
|
if args.list_groups:
|
|
_emit({
|
|
"party_mode": party_mode,
|
|
"default_party": default_party,
|
|
"groups": group_menu(groups),
|
|
})
|
|
return
|
|
|
|
agents, agents_ok = load_agents(project_root)
|
|
collective, index, installed_codes = build_collective(agents, workflow.get("party_members", []))
|
|
|
|
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
|
|
_emit({**group_detail(g, collective, index), "party_mode": party_mode})
|
|
return
|
|
|
|
# Default: the active roster to load on entry.
|
|
result = {"party_mode": party_mode, "groups": group_menu(groups),
|
|
"installed_agents_resolved": agents_ok}
|
|
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__":
|
|
main()
|