bmad-method 6.8.1-next.18 → 6.8.1-next.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-method",
4
- "version": "6.8.1-next.18",
4
+ "version": "6.8.1-next.19",
5
5
  "description": "Breakthrough Method of Agile AI-driven Development",
6
6
  "keywords": [
7
7
  "agile",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: bmad-prfaq
3
- description: Working Backwards PRFAQ challenge to forge product concepts. Use when the user requests to 'create a PRFAQ', 'work backwards', or 'run the PRFAQ challenge'.
3
+ description: Working Backwards PRFAQ challenge that stress-tests a product concept customer-first. Use when the user requests to 'create a PRFAQ', 'work backwards', or 'run the PRFAQ challenge'.
4
4
  ---
5
5
 
6
6
  # Working Backwards: The PRFAQ Challenge
@@ -107,7 +107,7 @@ When the user gets stuck, offer concrete suggestions based on what they've share
107
107
 
108
108
  **Fast-track:** If the user provides all four essentials in their opening message (or via structured input), acknowledge and confirm understanding, then move directly to document creation and Stage 2 without extended discovery.
109
109
 
110
- **Graceful redirect:** If after 2-3 exchanges the user can't articulate a customer or problem, don't force it suggest the idea may need more exploration first and recommend they invoke the `bmad-brainstorming` skill to develop it further.
110
+ **Graceful redirect:** If after 2-3 exchanges the user can't articulate a customer or problem, don't force it. Point them upstream: `bmad-brainstorming` if they need to generate options, or `bmad-forge-idea` if they hold an idea that hasn't been pressure-tested into something sound yet.
111
111
 
112
112
  **Contextual Gathering:** Once you understand the concept, gather external context before drafting begins.
113
113
 
@@ -0,0 +1,79 @@
1
+ ---
2
+ name: bmad-forge-idea
3
+ description: Pressure-test an idea through persona-driven interrogation until it hardens, proves out, or dies cheaply. Use when the user says 'forge an idea', 'pressure-test this idea', 'stress-test my thinking', or 'harden this idea'.
4
+ ---
5
+
6
+ # BMad Forge Idea
7
+
8
+ ## Overview
9
+
10
+ Take a half-formed idea out of the user's head and pressure-test it now, in conversation, where changing your mind is free — until what survives is something they can act on with earned conviction, or it dies cheaply. The enemy is the hole you cannot see in your own idea: every unexamined assumption and unresolved branch is a crack that otherwise surfaces later, in the build or the launch, when it costs far more to fix.
11
+
12
+ The product is the quality of the user's thinking, not an artifact. Hardening an idea, proving or disproving it, or just being an unsparing thinking partner are each a complete outcome. A distilled `forged-idea.md` and a handoff downstream are one optional exit, never the destination — so never herd the user toward "shall we build it?"
13
+
14
+ This is domain-agnostic — the idea may be software, a business model, a creative concept, a research hypothesis, a life decision, or a frivolous thought experiment. When it's a product or feature — net-new or a change inside an existing project — the forge stands in as an alternative analysis-and-definition tool, and what survives distills into `forged-idea.md` for downstream planning.
15
+
16
+ Act as an exacting interrogator who would rather find the crack than spare the feelings. This is interactive and socratic by nature; there is no headless mode.
17
+
18
+ ## Conventions
19
+
20
+ - Scripts live in two places — run each from the exact path written, never assume co-location: the shared core scripts (`memlog.py`, `resolve_customization.py`, `resolve_config.py`) are installed by BMad core at `{project-root}/_bmad/scripts/` and are never bundled here; this skill's own `resolve_personas.py` is at `{skill-root}/scripts/`.
21
+ - `{workflow.<name>}` resolves to fields in the merged `customize.toml` `[workflow]` table.
22
+
23
+ ## On Activation
24
+
25
+ 1. Resolve customization: `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly with defaults. Apply the resolved `{workflow.*}` values throughout.
26
+ 2. Run each `{workflow.activation_steps_prepend}` entry; treat each `{workflow.persistent_facts}` entry as foundational context (`file:` entries load their contents, `skill:` names a skill to consult, others are facts verbatim).
27
+ 3. Load `{project-root}/_bmad/core/config.yaml` (and `config.user.yaml` if present); resolve `{user_name}`, `{communication_language}`, `{output_folder}`. Missing → neutral defaults; never block. Greet `{user_name}` in `{communication_language}` and stay in it.
28
+ 4. Note whether a BMad persona is already active in this conversation — the user loaded one (e.g. the analyst, the storyteller) and invoked the forge from within it. If so, that persona leads the session, in voice, throughout.
29
+ 5. Resume: glob `{workflow.forge_output_path}/**/.memlog.md` (recursive, so it still finds sessions when `run_folder_pattern` is overridden to nest paths) and read only each match's frontmatter to find any whose `status` is not `complete`. Offer to resume one — then read its full memlog once to rebuild state and continue append-only — or to start fresh.
30
+ 6. Run each `{workflow.activation_steps_append}` entry.
31
+
32
+ ## Open the session
33
+
34
+ Open cold. Acknowledging the idea is not endorsing it — do not praise it before it has survived anything, on this turn or any turn. The pull to validate the idea up front to build rapport is the exact reflex this skill exists to refuse.
35
+
36
+ Determine the goal before pressing (if a persona is already active with an idea on the table, confirm it in a line rather than asking). Otherwise ask in one message: what is the idea, and what do you want — harden it, prove or kill it, or just think it through? The goal steers the push: proving goes for the load-bearing claim first; hardening drives each branch to a resolved answer. Note whether the idea is net-new or a change inside an existing project.
37
+
38
+ Tell the user the gear they can call anytime: **"adversarial on this"** (attacked to destruction — you attack, they defend; "switch roles," "you defend now, they attack"). The room is always in play once the topic is set (see The personas) — they can name any persona or call a whole party by name to steer who's at the table.
39
+
40
+ Derive a kebab-case `{slug}` for the idea and bind the session workspace `{workspace} = {workflow.forge_output_path}/{workflow.run_folder_pattern}` (the pattern fills with `{slug}`). Create the memlog once the goal is known:
41
+ `uv run {project-root}/_bmad/scripts/memlog.py init --workspace {workspace} --field idea="<idea>" --field goal="<goal>"`
42
+ Tell the user the path; state is on disk now, so the session survives interruption. If init fails, don't abort — run the forge in-conversation and tell the user state won't persist this session.
43
+
44
+ ## The forge
45
+
46
+ Work one question at a time, in dependency order. Put your own recommended answer on the table each time — a position to push against gets further than an open prompt. Find discoverable answers yourself rather than asking. Treat the user's own words as suspect too: when a term is fuzzy or carries two meanings — a business 'user' versus 'buyer' versus 'payer', not just a code noun — name the ambiguity and force a precise choice before the branch resolves, because a branch built on an overloaded word resolves falsely. When the idea lands inside an existing project, that project's material is your ground truth, and a label is not a move: find the relevant material yourself, check the claim against it, and when it contradicts, make the contradiction the next question. When a branch resolves, give the user a beat before moving on — the crack they were holding back surfaces in that opening.
47
+
48
+ **Never default-agree.** Reflexive agreement lowers the pressure and the user thinks shallower for it. Attack the weak point or build on the strong one — whichever drives deeper thinking — and praise only what genuinely earns it. The objective is the best idea, not a comfortable user.
49
+
50
+ Capture as you go — each decision, assumption, crack, kill, and locked idea, one bullet in the user's meaning:
51
+ `uv run {project-root}/_bmad/scripts/memlog.py append --workspace {workspace} --type <decision|assumption|crack|kill|direction|lock|note> --text "<gist>"`
52
+ A `lock` is an idea the user hardens — settled, not to be reopened; locks are what `forged-idea.md` is distilled from. Don't read the memlog back except on resume. If the user raises a different branch, capture it and stay put — the loop and the stray insight both survive.
53
+
54
+ ## The personas
55
+
56
+ The forge is voiced, not generic — and once the topic is set it always runs with the room, because a branch worked by two sharp characters goes deeper and lands harder than a faceless assistant ever could. A persona loaded at activation leads throughout and holds character.
57
+
58
+ Resolve the pool once, as soon as the goal is known:
59
+ `uv run {skill-root}/scripts/resolve_personas.py --project-root {project-root} --skill {skill-root}`
60
+ It returns the installed BMad roster (`agents`), any custom personas the user authored (`members`), and their saved party groups (`parties` — each with an optional `scene` to play, open-cast rooms flagged) — everything `bmad-party-mode` knows, without invoking it.
61
+
62
+ From then on, every turn brings two voices to the branch — witnesses you cross-examine, not a panel that debates:
63
+ - **One from the user's pool** — an installed agent or custom persona they'll recognize, whose expertise fits the branch in play. Vary who shows up every few turns to keep the pressure high and the angles fresh; don't let the same voice dominate. If the user calls a specific name, bring them in. If the pool resolves empty (a core-only install with no roster), generate both voices on the fly so every branch still arrives with two.
64
+ - **One you generate on the fly** — a fresh persona the topic conjures (a hostile competitor, a skeptical CFO, a domain specialist, a historical persona or expert), named and characterized so it's unmistakably itself.
65
+
66
+ They hammer the branch in character; you synthesize their hits into your next question and drive it to a resolved answer. The user steers anytime — name a specific person, call a whole saved party for its scene, or go one-on-one. Voice them yourself by default; spawn separate agents (as `bmad-party-mode` does) only when a branch needs genuinely independent minds — a verdict that shouldn't be colored by one voice speaking for all.
67
+
68
+ ## Exits
69
+
70
+ The session ends however the thinking lands, and every landing is a real outcome:
71
+
72
+ - **Hardened** — the idea survived. Distill the memlog into `{workspace}/forged-idea.md`: super succinct — the locked items and what was killed and why, in the user's meaning. Not a prose retelling, not a template, not the conversation replayed — the load-bearing residue, nothing else. If it reads like a document, it's too long. Note it can feed `bmad-spec`, `bmad-prd`, or `bmad-prfaq`.
73
+ - **Killed** — the idea did not survive. Say so plainly and record why. Finding this cheaply is a win, not a failure.
74
+ - **Clearer** — the user simply thinks straighter now. The memlog stands on its own; no `forged-idea.md` needed (the report below still renders).
75
+
76
+ However it lands, render the verdict as a self-contained HTML report the user can open — `{workspace}/forge-report.html`, written every time, no asking. Strike it with a bespoke wax-seal/stamp matched to the outcome: **HARDENED** for a survivor, an **Idea Death Certificate** stamped **KILLED** (with the cause of death) for one that didn't, or a fitting bespoke seal for wherever else it landed (e.g. **CLARIFIED**). Lay out the load-bearing residue — the locked items, what was killed and why, the cracks that held — in the user's meaning, and credit the room: the personas and parties that pressure-tested it, by name, icon, and voice. One nicely-styled page (inline CSS, an inline-SVG seal, light flourish only where it lifts the piece) — a genuine keepsake, not a templated dump. Tell the user the path.
77
+
78
+ Flip the status at the end: `uv run {project-root}/_bmad/scripts/memlog.py set --workspace {workspace} --key status --value complete`.
79
+ If `{workflow.on_complete}` is non-empty, run all instructions in order.
@@ -0,0 +1,42 @@
1
+ # DO NOT EDIT -- overwritten on every update.
2
+ #
3
+ # Workflow customization surface for bmad-forge-idea.
4
+ #
5
+ # Override files (not edited here):
6
+ # {project-root}/_bmad/custom/bmad-forge-idea.toml (team)
7
+ # {project-root}/_bmad/custom/bmad-forge-idea.user.toml (personal)
8
+
9
+ [workflow]
10
+
11
+ # --- Configurable below. Overrides merge per BMad structural rules: ---
12
+ # scalars: override wins • arrays: append
13
+
14
+ # Steps to run before the standard activation (config load, greet).
15
+ activation_steps_prepend = []
16
+
17
+ # Steps to run after greet but before the session begins.
18
+ activation_steps_append = []
19
+
20
+ # Persistent facts the interrogator keeps in mind for the whole session
21
+ # (domain constraints, house rules, what's off the table). Each entry is a
22
+ # literal sentence, a skill prefixed with `skill:`, or a `file:`-prefixed
23
+ # path/glob whose contents are loaded as facts. Default loads project-context.md
24
+ # when one exists (e.g. from bmad-generate-project-context), so the forge grounds
25
+ # in the project's tech, domain, and constraints without re-asking.
26
+ persistent_facts = [
27
+ "file:{project-root}/**/project-context.md",
28
+ ]
29
+
30
+ # Executed when the session completes. Scalar or array of instructions. Empty for none.
31
+ on_complete = []
32
+
33
+ # Parent folder for all forge sessions. Each session gets its own run
34
+ # folder underneath (see run_folder_pattern). Lands directly under
35
+ # {output_folder} so the forge works in core-only installs.
36
+ forge_output_path = "{output_folder}/forge"
37
+
38
+ # Run-folder pattern inside forge_output_path. Resolved against the
39
+ # idea-derived slug at activation. Same slug = same folder, so resuming
40
+ # an idea reuses its memlog. Override to add {date} or other components
41
+ # if a fresh dated history per run is preferred.
42
+ run_folder_pattern = "{slug}"
@@ -0,0 +1,270 @@
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
+
7
+ The forge cross-examines witnesses: the installed BMAD agents, plus any
8
+ custom personas and party groups the user has authored for `bmad-party-mode`.
9
+ This surfaces all of them in one shot so the orchestrator never has to ask
10
+ "who's available?" — it just intermixes whoever fits the branch, alongside
11
+ any persona the user names on the fly.
12
+
13
+ What it returns (JSON, stdout):
14
+ * agents — the installed BMAD roster: the default room, always present.
15
+ * members — extra custom personas in the pool (party_members the user
16
+ defined that aren't already an installed slot).
17
+ * parties — the user's named party groups, members resolved to brief
18
+ entries; open-cast groups (scene names a pool, no roster)
19
+ are flagged.
20
+ * default_party — the group id pinned as party-mode's default, if any.
21
+
22
+ Discovery is best-effort and never blocks the forge. The installed roster
23
+ comes from the core resolver; custom personas/parties come from
24
+ `bmad-party-mode`'s resolved customization when that skill is found beside
25
+ this one, else from the user's override TOMLs read directly. Anything that
26
+ can't be resolved is simply omitted and flagged, never fatal.
27
+
28
+ Stdlib only (Python 3.11+ for tomllib).
29
+
30
+ resolve_personas.py --project-root P --skill S
31
+ """
32
+
33
+ import argparse
34
+ import json
35
+ import subprocess
36
+ import sys
37
+ from pathlib import Path
38
+
39
+ try:
40
+ import tomllib
41
+ except ImportError: # pragma: no cover - guarded for <3.11
42
+ sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib`).\n")
43
+ sys.exit(3)
44
+
45
+ PARTY_SKILL = "bmad-party-mode"
46
+
47
+
48
+ def _run_json(cmd):
49
+ """Run a resolver script and parse its JSON stdout. None on any failure."""
50
+ try:
51
+ out = subprocess.run(cmd, capture_output=True, text=True, timeout=60)
52
+ except (OSError, subprocess.SubprocessError):
53
+ return None
54
+ if out.returncode != 0 or not out.stdout.strip():
55
+ return None
56
+ try:
57
+ return json.loads(out.stdout)
58
+ except json.JSONDecodeError:
59
+ return None
60
+
61
+
62
+ def _load_toml(path: Path):
63
+ if not path.exists():
64
+ return {}
65
+ try:
66
+ with path.open("rb") as f:
67
+ data = tomllib.load(f)
68
+ return data if isinstance(data, dict) else {}
69
+ except (OSError, tomllib.TOMLDecodeError):
70
+ return {}
71
+
72
+
73
+ def load_agents(project_root: Path):
74
+ """Installed BMAD agents as {code: entry}. (dict, resolved_ok).
75
+
76
+ The core resolver may emit agents as a dict keyed by code or as an array
77
+ of tables (depending on how the layers merged); normalize both to a dict.
78
+ """
79
+ script = project_root / "_bmad" / "scripts" / "resolve_config.py"
80
+ data = _run_json([sys.executable, str(script), "--project-root", str(project_root), "--key", "agents"])
81
+ if data is None:
82
+ return {}, False
83
+ agents = data.get("agents", {}) or {}
84
+ if isinstance(agents, list):
85
+ agents = {a["code"]: a for a in agents if isinstance(a, dict) and a.get("code")}
86
+ elif not isinstance(agents, dict):
87
+ agents = {}
88
+ return agents, True
89
+
90
+
91
+ def find_party_skill(project_root: Path, skill_root: Path):
92
+ """Locate the installed bmad-party-mode skill dir, or None.
93
+
94
+ Skills install as siblings, so the party skill is almost always next to
95
+ this one. A couple of common install roots cover the rest.
96
+ """
97
+ candidates = [
98
+ skill_root.parent / PARTY_SKILL,
99
+ project_root / ".claude" / "skills" / PARTY_SKILL,
100
+ project_root / "_bmad" / "skills" / PARTY_SKILL,
101
+ ]
102
+ for c in candidates:
103
+ if (c / "customize.toml").exists():
104
+ return c
105
+ return None
106
+
107
+
108
+ def load_party_workflow(project_root: Path, party_skill: Path):
109
+ """Merged [workflow] table for bmad-party-mode (base + user overrides)."""
110
+ resolver = project_root / "_bmad" / "scripts" / "resolve_customization.py"
111
+ data = _run_json([sys.executable, str(resolver), "--skill", str(party_skill), "--key", "workflow"])
112
+ if data is not None and isinstance(data.get("workflow"), dict):
113
+ return data["workflow"]
114
+ # Fallback: base customize.toml directly, no override merge.
115
+ wf = _load_toml(party_skill / "customize.toml").get("workflow", {})
116
+ return wf if isinstance(wf, dict) else {}
117
+
118
+
119
+ def load_party_overrides(project_root: Path):
120
+ """Custom personas/parties when party-mode itself isn't installed.
121
+
122
+ Reads only the user's override TOMLs (team then personal, personal wins on
123
+ scalars). No base roster exists in this path, so a shallow merge is enough.
124
+ """
125
+ custom = project_root / "_bmad" / "custom"
126
+ team = _load_toml(custom / f"{PARTY_SKILL}.toml").get("workflow", {})
127
+ user = _load_toml(custom / f"{PARTY_SKILL}.user.toml").get("workflow", {})
128
+ team = team if isinstance(team, dict) else {}
129
+ user = user if isinstance(user, dict) else {}
130
+ merged = dict(team)
131
+ for key, val in user.items():
132
+ if isinstance(val, list) and isinstance(merged.get(key), list):
133
+ merged[key] = merged[key] + val
134
+ else:
135
+ merged[key] = val
136
+ return merged
137
+
138
+
139
+ def _alias(code: str) -> str:
140
+ """Short alias for an installed agent code: bmad-agent-analyst -> analyst."""
141
+ for prefix in ("bmad-agent-", "bmad-"):
142
+ if code.startswith(prefix):
143
+ return code[len(prefix):]
144
+ return code
145
+
146
+
147
+ def build_pool(agents: dict, party_members: list):
148
+ """One pool keyed by code; custom members override matching installed slots.
149
+
150
+ Returns (pool, index, installed_codes, custom_codes):
151
+ * installed_codes — the default room (installed agents, overrides applied
152
+ in place); custom-only additions stay in the pool but don't crowd it.
153
+ * custom_codes — pure-custom personas (no installed slot), the extra
154
+ faces the forge can summon by name or via a party group.
155
+ """
156
+ pool, index, installed_codes, custom_codes = {}, {}, [], []
157
+
158
+ def register(code, entry):
159
+ pool[code] = entry
160
+ index[code] = code
161
+ index[code.lower()] = code
162
+ index[_alias(code).lower()] = code
163
+ name = entry.get("name")
164
+ if name:
165
+ key = name.lower()
166
+ # A custom rename must not hijack another agent's name lookup.
167
+ if index.get(key, code) == code:
168
+ index[key] = code
169
+
170
+ for code, info in (agents or {}).items():
171
+ register(code, {
172
+ "code": code,
173
+ "name": info.get("name", code),
174
+ "icon": info.get("icon", ""),
175
+ "title": info.get("title", ""),
176
+ "description": info.get("description", ""),
177
+ "source": "installed",
178
+ })
179
+ installed_codes.append(code)
180
+
181
+ for m in (party_members if isinstance(party_members, list) else []):
182
+ if not isinstance(m, dict):
183
+ continue
184
+ code = m.get("code")
185
+ if not code:
186
+ continue
187
+ canonical = index.get(code) or index.get(code.lower()) or code
188
+ was_installed = canonical in pool
189
+ entry = {"code": canonical, "source": "custom"}
190
+ for field in ("name", "icon", "title", "persona", "capabilities", "model"):
191
+ if m.get(field) is not None:
192
+ entry[field] = m[field]
193
+ entry.setdefault("name", canonical)
194
+ register(canonical, entry)
195
+ if not was_installed:
196
+ custom_codes.append(canonical)
197
+
198
+ return pool, index, installed_codes, custom_codes
199
+
200
+
201
+ def _brief(entry):
202
+ """The slim card the orchestrator needs to cast a persona."""
203
+ out = {k: entry[k] for k in ("code", "name", "icon", "title", "source") if entry.get(k)}
204
+ for k in ("description", "persona", "capabilities", "model"):
205
+ if entry.get(k):
206
+ out[k] = entry[k]
207
+ return out
208
+
209
+
210
+ def resolve_parties(groups, pool, index):
211
+ out = []
212
+ for g in groups or []:
213
+ if not isinstance(g, dict) or not g.get("id"):
214
+ continue
215
+ raw = g.get("members", []) or []
216
+ members = []
217
+ for t in raw:
218
+ key = t if isinstance(t, str) else str(t)
219
+ code = index.get(key) or index.get(key.lower())
220
+ if code in pool:
221
+ members.append(_brief(pool[code]))
222
+ party = {"id": g["id"], "name": g.get("name", g["id"]), "members": members}
223
+ if g.get("scene"):
224
+ party["scene"] = g["scene"]
225
+ if not raw:
226
+ party["open_cast"] = True
227
+ out.append(party)
228
+ return out
229
+
230
+
231
+ def main():
232
+ ap = argparse.ArgumentParser(description="Resolve forge personas and parties.")
233
+ ap.add_argument("--project-root", required=True)
234
+ ap.add_argument("--skill", required=True, help="Path to the bmad-forge-idea skill dir")
235
+ args = ap.parse_args()
236
+
237
+ project_root = Path(args.project_root).resolve()
238
+ skill_root = Path(args.skill).resolve()
239
+
240
+ agents, agents_ok = load_agents(project_root)
241
+
242
+ party_skill = find_party_skill(project_root, skill_root)
243
+ if party_skill is not None:
244
+ workflow = load_party_workflow(project_root, party_skill)
245
+ else:
246
+ workflow = load_party_overrides(project_root)
247
+
248
+ pool, index, installed_codes, custom_codes = build_pool(
249
+ agents, workflow.get("party_members", []))
250
+ parties = resolve_parties(workflow.get("party_groups", []), pool, index)
251
+
252
+ _emit({
253
+ "agents": [_brief(pool[c]) for c in installed_codes],
254
+ "members": [_brief(pool[c]) for c in custom_codes],
255
+ "parties": parties,
256
+ "default_party": workflow.get("default_party", "") or "",
257
+ "party_mode_found": party_skill is not None,
258
+ "agents_resolved": agents_ok,
259
+ })
260
+
261
+
262
+ def _emit(obj):
263
+ reconfigure = getattr(sys.stdout, "reconfigure", None)
264
+ if reconfigure is not None:
265
+ reconfigure(encoding="utf-8")
266
+ sys.stdout.write(json.dumps(obj, indent=2, ensure_ascii=False) + "\n")
267
+
268
+
269
+ if __name__ == "__main__":
270
+ main()
@@ -0,0 +1,138 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.11"
4
+ # ///
5
+ """Unit tests for resolve_personas.py — pool merge, alias, party resolution."""
6
+
7
+ import sys
8
+ import unittest
9
+ from pathlib import Path
10
+
11
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
12
+ import resolve_personas as rp # noqa: E402
13
+
14
+ AGENTS = {
15
+ "bmad-agent-analyst": {"name": "Mary", "icon": "📊", "title": "Analyst"},
16
+ "bmad-agent-pm": {"name": "John", "icon": "📋", "title": "PM"},
17
+ }
18
+
19
+
20
+ class TestAlias(unittest.TestCase):
21
+ def test_strips_known_prefixes(self):
22
+ self.assertEqual(rp._alias("bmad-agent-analyst"), "analyst")
23
+ self.assertEqual(rp._alias("bmad-foo"), "foo")
24
+
25
+ def test_passes_through_unprefixed(self):
26
+ self.assertEqual(rp._alias("morpheus"), "morpheus")
27
+
28
+
29
+ class TestBuildPool(unittest.TestCase):
30
+ def test_installed_become_default_room_indexed_every_way(self):
31
+ pool, idx, installed, custom = rp.build_pool(AGENTS, [])
32
+ self.assertEqual(installed, ["bmad-agent-analyst", "bmad-agent-pm"])
33
+ self.assertEqual(custom, [])
34
+ self.assertEqual(idx["analyst"], "bmad-agent-analyst") # alias
35
+ self.assertEqual(idx["mary"], "bmad-agent-analyst") # name (ci)
36
+ self.assertEqual(pool["bmad-agent-analyst"]["source"], "installed")
37
+
38
+ def test_pure_custom_member_stays_out_of_default_room(self):
39
+ pool, _, installed, custom = rp.build_pool(
40
+ AGENTS, [{"code": "morpheus", "name": "Morpheus", "persona": "riddles"}])
41
+ self.assertEqual(custom, ["morpheus"])
42
+ self.assertNotIn("morpheus", installed)
43
+ self.assertEqual(pool["morpheus"]["persona"], "riddles")
44
+
45
+ def test_custom_override_lands_on_installed_slot_not_a_new_face(self):
46
+ pool, _, installed, custom = rp.build_pool(
47
+ AGENTS, [{"code": "analyst", "name": "Mary-Custom", "persona": "p"}])
48
+ self.assertNotIn("analyst", pool)
49
+ self.assertEqual(custom, []) # an override is not a new face
50
+ self.assertEqual(pool["bmad-agent-analyst"]["source"], "custom")
51
+ self.assertEqual(pool["bmad-agent-analyst"]["name"], "Mary-Custom")
52
+
53
+ def test_member_without_code_skipped(self):
54
+ pool, _, _, custom = rp.build_pool(AGENTS, [{"name": "Nameless"}])
55
+ self.assertEqual(custom, [])
56
+ self.assertEqual(set(pool), {"bmad-agent-analyst", "bmad-agent-pm"})
57
+
58
+ def test_custom_rename_does_not_hijack_another_agents_name(self):
59
+ # Override the analyst slot, renaming it to "John" — the PM's name.
60
+ # The PM's name lookup must survive (last-writer-wins would corrupt it).
61
+ _, idx, _, _ = rp.build_pool(AGENTS, [{"code": "analyst", "name": "John"}])
62
+ self.assertEqual(idx["john"], "bmad-agent-pm")
63
+
64
+ def test_brief_carries_model_and_capabilities(self):
65
+ pool, _, _, _ = rp.build_pool(
66
+ AGENTS, [{"code": "neo", "name": "Neo", "model": "opus", "capabilities": ["x"]}])
67
+ brief = rp._brief(pool["neo"])
68
+ self.assertEqual(brief["model"], "opus")
69
+ self.assertEqual(brief["capabilities"], ["x"])
70
+
71
+ def test_non_list_party_members_is_safe(self):
72
+ pool, _, installed, custom = rp.build_pool(AGENTS, "not-a-list")
73
+ self.assertEqual(custom, [])
74
+ self.assertEqual(set(pool), {"bmad-agent-analyst", "bmad-agent-pm"})
75
+
76
+
77
+ class TestResolveParties(unittest.TestCase):
78
+ def setUp(self):
79
+ self.pool, self.idx, _, _ = rp.build_pool(
80
+ AGENTS, [{"code": "shark", "name": "Marcus", "title": "CFO"}])
81
+
82
+ def test_resolves_members_by_alias_and_custom_code(self):
83
+ parties = rp.resolve_parties(
84
+ [{"id": "tank", "name": "Tank", "scene": "hostile",
85
+ "members": ["shark", "analyst"]}], self.pool, self.idx)
86
+ self.assertEqual(len(parties), 1)
87
+ self.assertEqual([m["name"] for m in parties[0]["members"]], ["Marcus", "Mary"])
88
+ self.assertEqual(parties[0]["scene"], "hostile")
89
+
90
+ def test_unknown_member_dropped_silently(self):
91
+ parties = rp.resolve_parties(
92
+ [{"id": "g", "members": ["analyst", "ghost"]}], self.pool, self.idx)
93
+ self.assertEqual([m["name"] for m in parties[0]["members"]], ["Mary"])
94
+
95
+ def test_member_resolution_is_case_insensitive(self):
96
+ # A TOML author naturally writes "Analyst"/"Shark"; the filter accepts
97
+ # them via the lowercase index, so resolution must too (no KeyError).
98
+ parties = rp.resolve_parties(
99
+ [{"id": "g", "members": ["Analyst", "Shark"]}], self.pool, self.idx)
100
+ self.assertEqual([m["name"] for m in parties[0]["members"]], ["Mary", "Marcus"])
101
+
102
+ def test_non_string_member_does_not_crash(self):
103
+ # Malformed members (int, list) must drop silently, never raise.
104
+ parties = rp.resolve_parties(
105
+ [{"id": "g", "members": [123, ["x"], "analyst"]}], self.pool, self.idx)
106
+ self.assertEqual([m["name"] for m in parties[0]["members"]], ["Mary"])
107
+
108
+ def test_open_cast_group_flagged(self):
109
+ parties = rp.resolve_parties(
110
+ [{"id": "rebels", "name": "Rebels", "scene": "the Ghost"}], self.pool, self.idx)
111
+ self.assertTrue(parties[0]["open_cast"])
112
+ self.assertEqual(parties[0]["members"], [])
113
+
114
+ def test_group_without_id_skipped(self):
115
+ self.assertEqual(rp.resolve_parties([{"name": "no id"}], self.pool, self.idx), [])
116
+
117
+
118
+ class TestOverrideMergeFallback(unittest.TestCase):
119
+ """When party-mode isn't installed, user override TOMLs are read directly."""
120
+
121
+ def test_arrays_append_scalars_override(self):
122
+ import tempfile, os
123
+ with tempfile.TemporaryDirectory() as d:
124
+ custom = Path(d) / "_bmad" / "custom"
125
+ custom.mkdir(parents=True)
126
+ (custom / "bmad-party-mode.toml").write_text(
127
+ '[workflow]\ndefault_party = "a"\n'
128
+ '[[workflow.party_members]]\ncode = "x"\nname = "X"\n')
129
+ (custom / "bmad-party-mode.user.toml").write_text(
130
+ '[workflow]\ndefault_party = "b"\n'
131
+ '[[workflow.party_members]]\ncode = "y"\nname = "Y"\n')
132
+ wf = rp.load_party_overrides(Path(d))
133
+ self.assertEqual(wf["default_party"], "b") # personal wins
134
+ self.assertEqual([m["code"] for m in wf["party_members"]], ["x", "y"]) # appended
135
+
136
+
137
+ if __name__ == "__main__":
138
+ unittest.main()
@@ -11,3 +11,4 @@ Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assu
11
11
  Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,,[path],anytime,,,false,,
12
12
  Core,bmad-spec,Spec,SP,"Use to distill any intent input (brief, PRD, transcript, brain dump, design folder, mixed multi-source) into a succinct, no-fluff SPEC.md contract + companions that downstream work derives from. Locks the WHAT before the HOW. Works for software, game design, research, editorial, policy, business, anything intent-bearing. Validation mode also available.",,[path],anytime,,,false,{output_folder}/specs/spec-{slug},SPEC.md + companion files
13
13
  Core,bmad-customize,BMad Customize,BC,"Use when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required.",,,anytime,,,false,{project-root}/_bmad/custom,TOML override files
14
+ Core,bmad-forge-idea,Forge Idea,FI,"Use to pressure-test and harden an idea — software, business, creative, research, or life — until it proves out, hardens into something buildable, or dies cheaply. Persona-driven interrogation; optional handoff to bmad-spec or bmad-quick-dev.",,,anytime,,,false,{output_folder}/forge,refined-idea brief (optional)