resolve_personas.py 9.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. #!/usr/bin/env python3
  2. # /// script
  3. # requires-python = ">=3.11"
  4. # ///
  5. """Resolve the personas and parties the forge can bring into the room.
  6. The forge cross-examines witnesses: the installed BMAD agents, plus any
  7. custom personas and party groups the user has authored for `bmad-party-mode`.
  8. This surfaces all of them in one shot so the orchestrator never has to ask
  9. "who's available?" — it just intermixes whoever fits the branch, alongside
  10. any persona the user names on the fly.
  11. What it returns (JSON, stdout):
  12. * agents — the installed BMAD roster: the default room, always present.
  13. * members — extra custom personas in the pool (party_members the user
  14. defined that aren't already an installed slot).
  15. * parties — the user's named party groups, members resolved to brief
  16. entries; open-cast groups (scene names a pool, no roster)
  17. are flagged.
  18. * default_party — the group id pinned as party-mode's default, if any.
  19. Discovery is best-effort and never blocks the forge. The installed roster
  20. comes from the core resolver; custom personas/parties come from
  21. `bmad-party-mode`'s resolved customization when that skill is found beside
  22. this one, else from the user's override TOMLs read directly. Anything that
  23. can't be resolved is simply omitted and flagged, never fatal.
  24. Stdlib only (Python 3.11+ for tomllib).
  25. resolve_personas.py --project-root P --skill S
  26. """
  27. import argparse
  28. import json
  29. import subprocess
  30. import sys
  31. from pathlib import Path
  32. try:
  33. import tomllib
  34. except ImportError: # pragma: no cover - guarded for <3.11
  35. sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib`).\n")
  36. sys.exit(3)
  37. PARTY_SKILL = "bmad-party-mode"
  38. def _run_json(cmd):
  39. """Run a resolver script and parse its JSON stdout. None on any failure."""
  40. try:
  41. out = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
  42. except (OSError, subprocess.SubprocessError):
  43. return None
  44. if out.returncode != 0 or not out.stdout.strip():
  45. return None
  46. try:
  47. return json.loads(out.stdout)
  48. except json.JSONDecodeError:
  49. return None
  50. def _load_toml(path: Path):
  51. if not path.exists():
  52. return {}
  53. try:
  54. with path.open("rb") as f:
  55. data = tomllib.load(f)
  56. return data if isinstance(data, dict) else {}
  57. except (OSError, tomllib.TOMLDecodeError):
  58. return {}
  59. def load_agents(project_root: Path):
  60. """Installed BMAD agents as {code: entry}. (dict, resolved_ok).
  61. The core resolver may emit agents as a dict keyed by code or as an array
  62. of tables (depending on how the layers merged); normalize both to a dict.
  63. """
  64. script = project_root / "_bmad" / "scripts" / "resolve_config.py"
  65. data = _run_json([sys.executable, str(script), "--project-root", str(project_root), "--key", "agents"])
  66. if data is None:
  67. return {}, False
  68. agents = data.get("agents", {}) or {}
  69. if isinstance(agents, list):
  70. agents = {a["code"]: a for a in agents if isinstance(a, dict) and a.get("code")}
  71. elif not isinstance(agents, dict):
  72. agents = {}
  73. return agents, True
  74. def find_party_skill(project_root: Path, skill_root: Path):
  75. """Locate the installed bmad-party-mode skill dir, or None.
  76. Skills install as siblings, so the party skill is almost always next to
  77. this one. A couple of common install roots cover the rest.
  78. """
  79. candidates = [
  80. skill_root.parent / PARTY_SKILL,
  81. project_root / ".claude" / "skills" / PARTY_SKILL,
  82. project_root / "_bmad" / "skills" / PARTY_SKILL,
  83. ]
  84. for c in candidates:
  85. if (c / "customize.toml").exists():
  86. return c
  87. return None
  88. def load_party_workflow(project_root: Path, party_skill: Path):
  89. """Merged [workflow] table for bmad-party-mode (base + user overrides)."""
  90. resolver = project_root / "_bmad" / "scripts" / "resolve_customization.py"
  91. data = _run_json([sys.executable, str(resolver), "--skill", str(party_skill), "--key", "workflow"])
  92. if data is not None and isinstance(data.get("workflow"), dict):
  93. return data["workflow"]
  94. # Fallback: base customize.toml directly, no override merge.
  95. wf = _load_toml(party_skill / "customize.toml").get("workflow", {})
  96. return wf if isinstance(wf, dict) else {}
  97. def load_party_overrides(project_root: Path):
  98. """Custom personas/parties when party-mode itself isn't installed.
  99. Reads only the user's override TOMLs (team then personal, personal wins on
  100. scalars). No base roster exists in this path, so a shallow merge is enough.
  101. """
  102. custom = project_root / "_bmad" / "custom"
  103. team = _load_toml(custom / f"{PARTY_SKILL}.toml").get("workflow", {})
  104. user = _load_toml(custom / f"{PARTY_SKILL}.user.toml").get("workflow", {})
  105. team = team if isinstance(team, dict) else {}
  106. user = user if isinstance(user, dict) else {}
  107. merged = dict(team)
  108. for key, val in user.items():
  109. if isinstance(val, list) and isinstance(merged.get(key), list):
  110. merged[key] = merged[key] + val
  111. else:
  112. merged[key] = val
  113. return merged
  114. def _alias(code: str) -> str:
  115. """Short alias for an installed agent code: bmad-agent-analyst -> analyst."""
  116. for prefix in ("bmad-agent-", "bmad-"):
  117. if code.startswith(prefix):
  118. return code[len(prefix):]
  119. return code
  120. def build_pool(agents: dict, party_members: list):
  121. """One pool keyed by code; custom members override matching installed slots.
  122. Returns (pool, index, installed_codes, custom_codes):
  123. * installed_codes — the default room (installed agents, overrides applied
  124. in place); custom-only additions stay in the pool but don't crowd it.
  125. * custom_codes — pure-custom personas (no installed slot), the extra
  126. faces the forge can summon by name or via a party group.
  127. """
  128. pool, index, installed_codes, custom_codes = {}, {}, [], []
  129. def register(code, entry):
  130. pool[code] = entry
  131. index[code] = code
  132. index[code.lower()] = code
  133. index[_alias(code).lower()] = code
  134. name = entry.get("name")
  135. if name:
  136. key = name.lower()
  137. # A custom rename must not hijack another agent's name lookup.
  138. if index.get(key, code) == code:
  139. index[key] = code
  140. for code, info in (agents or {}).items():
  141. register(code, {
  142. "code": code,
  143. "name": info.get("name", code),
  144. "icon": info.get("icon", ""),
  145. "title": info.get("title", ""),
  146. "description": info.get("description", ""),
  147. "source": "installed",
  148. })
  149. installed_codes.append(code)
  150. for m in (party_members if isinstance(party_members, list) else []):
  151. if not isinstance(m, dict):
  152. continue
  153. code = m.get("code")
  154. if not code:
  155. continue
  156. canonical = index.get(code) or index.get(code.lower()) or code
  157. was_installed = canonical in pool
  158. entry = {"code": canonical, "source": "custom"}
  159. for field in ("name", "icon", "title", "persona", "capabilities", "model"):
  160. if m.get(field) is not None:
  161. entry[field] = m[field]
  162. entry.setdefault("name", canonical)
  163. register(canonical, entry)
  164. if not was_installed:
  165. custom_codes.append(canonical)
  166. return pool, index, installed_codes, custom_codes
  167. def _brief(entry):
  168. """The slim card the orchestrator needs to cast a persona."""
  169. out = {k: entry[k] for k in ("code", "name", "icon", "title", "source") if entry.get(k)}
  170. for k in ("description", "persona", "capabilities", "model"):
  171. if entry.get(k):
  172. out[k] = entry[k]
  173. return out
  174. def resolve_parties(groups, pool, index):
  175. out = []
  176. for g in groups or []:
  177. if not isinstance(g, dict) or not g.get("id"):
  178. continue
  179. raw = g.get("members", []) or []
  180. members = []
  181. for t in raw:
  182. key = t if isinstance(t, str) else str(t)
  183. code = index.get(key) or index.get(key.lower())
  184. if code in pool:
  185. members.append(_brief(pool[code]))
  186. party = {"id": g["id"], "name": g.get("name", g["id"]), "members": members}
  187. if g.get("scene"):
  188. party["scene"] = g["scene"]
  189. if not raw:
  190. party["open_cast"] = True
  191. out.append(party)
  192. return out
  193. def main():
  194. ap = argparse.ArgumentParser(description="Resolve forge personas and parties.")
  195. ap.add_argument("--project-root", required=True)
  196. ap.add_argument("--skill", required=True, help="Path to the bmad-forge-idea skill dir")
  197. args = ap.parse_args()
  198. project_root = Path(args.project_root).resolve()
  199. skill_root = Path(args.skill).resolve()
  200. agents, agents_ok = load_agents(project_root)
  201. party_skill = find_party_skill(project_root, skill_root)
  202. if party_skill is not None:
  203. workflow = load_party_workflow(project_root, party_skill)
  204. else:
  205. workflow = load_party_overrides(project_root)
  206. pool, index, installed_codes, custom_codes = build_pool(
  207. agents, workflow.get("party_members", []))
  208. parties = resolve_parties(workflow.get("party_groups", []), pool, index)
  209. _emit({
  210. "agents": [_brief(pool[c]) for c in installed_codes],
  211. "members": [_brief(pool[c]) for c in custom_codes],
  212. "parties": parties,
  213. "default_party": workflow.get("default_party", "") or "",
  214. "party_mode_found": party_skill is not None,
  215. "agents_resolved": agents_ok,
  216. })
  217. def _emit(obj):
  218. reconfigure = getattr(sys.stdout, "reconfigure", None)
  219. if reconfigure is not None:
  220. reconfigure(encoding="utf-8")
  221. sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
  222. if __name__ == "__main__":
  223. main()