bmad 6.11

This commit is contained in:
2026-08-14 17:08:24 +02:00
parent 0edaef0be7
commit 373857c868
268 changed files with 26063 additions and 0 deletions
@@ -0,0 +1,233 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Serve the elicitation method catalog without loading it all into context.
The catalog is a CSV (num, category, method_name, description, output_pattern).
`description` is a one-line gist — enough to run the method; `output_pattern` is
a flexible flow guide (e.g. "assumptions → truths → new approach").
Commands:
categories list category names + counts (the cheap entry point)
list --category C [...] the index (num/category/name/gist) for those categories
list --all the whole catalog at once — deliberate; large, avoid interactively
show NAME_OR_NUM [...] full row for each method, matched by name or num
random [-n N] [--category C ...] [--exclude NAME ...] [--spread]
draw N at random; --spread forces category diversity
(at most one per category until categories run out) —
the reshuffle draw; --exclude skips already-shown methods
`list` refuses to run with neither --category nor --all: dumping the full catalog
into context must always be an explicit, deliberate choice.
`--extra SPEC` merges additional methods (customize.toml's `additional_methods`)
into every command. SPEC is either a JSON array literal (starts with `[`) or a
path to a JSON file; each item is {code, category, method_name, description,
output_pattern}. An extra whose method_name matches a catalog row
(case-insensitive) REPLACES it and keeps that row's num — retune a shipped
method; others append and get the next free nums, so new methods and whole new
categories are first-class and number-addressable everywhere.
Default output is lean tab-separated text for an LLM to read; --json for structured.
"""
import argparse
import csv
import json
import random
import sys
from pathlib import Path
DEFAULT_FILE = Path(__file__).resolve().parent.parent / "assets" / "methods.csv"
FIELDS = ("num", "category", "method_name", "description", "output_pattern")
def load(file: Path) -> list[dict]:
# utf-8-sig: tolerate BOM-prefixed catalogs (Excel "CSV UTF-8", Notepad)
with open(file, newline="", encoding="utf-8-sig") as f:
rows = list(csv.DictReader(f))
for r in rows:
for k in FIELDS:
r.setdefault(k, "")
r[k] = (r.get(k) or "").strip()
return rows
def load_extra(spec: str) -> list[dict]:
"""Parse the --extra overlay: a JSON array literal or a path to a JSON file."""
text = spec if spec.lstrip().startswith("[") else Path(spec).read_text(encoding="utf-8-sig")
data = json.loads(text)
if not isinstance(data, list):
raise ValueError("--extra must be a JSON array of objects")
rows = []
for item in data:
if not isinstance(item, dict):
raise ValueError(f"each --extra entry must be a JSON object, got: {item!r}")
row = {k: str(item.get(k) or "").strip() for k in FIELDS}
row["code"] = str(item.get("code") or "").strip() # kept for traceability
rows.append(row)
return rows
def merge_extra(rows: list[dict], extras: list[dict]) -> list[dict]:
"""Extras replace a catalog row with the same method_name (case-insensitive),
otherwise append — so overrides can retune shipped methods or grow the catalog.
A replacement inherits the shipped row's num; appended extras get the next
free nums, so every merged method stays addressable by number."""
merged = list(rows)
index = {r["method_name"].lower(): i for i, r in enumerate(merged)}
for e in extras:
key = e["method_name"].lower()
if key in index:
e = dict(e)
e["num"] = e["num"] or merged[index[key]]["num"]
merged[index[key]] = e
else:
index[key] = len(merged)
merged.append(dict(e))
next_num = max((int(r["num"]) for r in merged if r["num"].isdigit()), default=0) + 1
for r in merged:
if not r["num"]:
r["num"] = str(next_num)
next_num += 1
return merged
def categories(rows: list[dict]) -> list[tuple[str, int]]:
counts: dict[str, int] = {}
for r in rows:
counts[r["category"]] = counts.get(r["category"], 0) + 1
return sorted(counts.items())
def filter_cats(rows: list[dict], cats: list[str] | None) -> list[dict]:
if not cats:
return rows
wanted = {c.lower() for c in cats}
return [r for r in rows if r["category"].lower() in wanted]
def find(rows: list[dict], names: list[str]) -> tuple[list[dict], list[str]]:
"""Match each query by method_name or by num, case-insensitively."""
by_key: dict[str, dict] = {}
for r in rows:
by_key[r["method_name"].lower()] = r
if r["num"]:
by_key.setdefault(r["num"], r)
found, missing = [], []
for n in names:
r = by_key.get(n.strip().lower())
(found if r else missing).append(r if r else n)
return found, missing
def exclude(rows: list[dict], names: list[str] | None) -> list[dict]:
if not names:
return rows
skip = {n.strip().lower() for n in names}
return [r for r in rows if r["method_name"].lower() not in skip]
def spread_sample(rows: list[dict], n: int, rng: random.Random | None = None) -> list[dict]:
"""Draw n methods with maximum category diversity: shuffle the categories,
take one random method per category round-robin, wrapping only when there
are fewer categories than picks."""
rng = rng or random
by_cat: dict[str, list[dict]] = {}
for r in rows:
by_cat.setdefault(r["category"], []).append(r)
buckets = list(by_cat.values())
rng.shuffle(buckets)
for b in buckets:
rng.shuffle(b)
out: list[dict] = []
while buckets and len(out) < n:
exhausted = []
for b in buckets:
if len(out) >= n:
break
out.append(b.pop())
if not b:
exhausted.append(b)
buckets = [b for b in buckets if b not in exhausted]
return out
def fmt_categories(cats: list[tuple[str, int]], as_json: bool) -> str:
if as_json:
return json.dumps([{"category": c, "count": n} for c, n in cats])
return "\n".join(f"{c}\t{n}" for c, n in cats)
def fmt_rows(rows: list[dict], as_json: bool) -> str:
if as_json:
return json.dumps([{k: r[k] for k in FIELDS} for r in rows])
return "\n".join(
f"{r['num']}\t{r['category']}\t{r['method_name']}\t{r['description']}\t{r['output_pattern']}"
for r in rows
)
def main(argv: list[str] | None = None) -> int:
if hasattr(sys.stdout, "reconfigure"):
sys.stdout.reconfigure(encoding="utf-8") # catalog rows contain →; don't die on locale code pages
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--file", type=Path, default=DEFAULT_FILE, help="method CSV (default: sibling assets/methods.csv)")
p.add_argument("--extra", help="additional methods: a JSON array literal or a path to a JSON file")
p.add_argument("--json", action="store_true", help="emit structured JSON instead of lean text")
sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("categories", help="list category names + counts")
pl = sub.add_parser("list", help="the index for chosen categories (needs --category or --all)")
pl.add_argument("--category", action="append", help="filter to a category (repeatable)")
pl.add_argument("--all", action="store_true", help="dump the entire catalog (deliberate; large)")
ps = sub.add_parser("show", help="full row for each named method")
ps.add_argument("names", nargs="+", help="method names or nums")
pr = sub.add_parser("random", help="draw methods at random")
pr.add_argument("-n", type=int, default=1, help="how many (default 1)")
pr.add_argument("--category", action="append", help="restrict to a category (repeatable)")
pr.add_argument("--exclude", action="append", help="method name to skip (repeatable) — e.g. already shown")
pr.add_argument("--spread", action="store_true", help="force category diversity across the draw")
args = p.parse_args(argv)
if not args.file.is_file():
print(f"error: method file not found: {args.file}", file=sys.stderr)
return 2
rows = load(args.file)
if args.extra:
try:
rows = merge_extra(rows, load_extra(args.extra))
except (OSError, ValueError) as e:
print(f"error: could not read --extra: {e}", file=sys.stderr)
return 2
if args.cmd == "categories":
print(fmt_categories(categories(rows), args.json))
elif args.cmd == "list":
if not args.category and not args.all:
print(
"error: `list` needs --category (one or more) — or --all to dump the whole "
"catalog on purpose. Use `categories` for the cheap map, or `random` to draw blind.",
file=sys.stderr,
)
return 2
print(fmt_rows(filter_cats(rows, args.category), args.json))
elif args.cmd == "show":
found, missing = find(rows, args.names)
for m in missing:
print(f"# not found: {m}", file=sys.stderr)
if not found:
return 1
print(fmt_rows(found, args.json))
elif args.cmd == "random":
pool = exclude(filter_cats(rows, args.category), args.exclude)
if not pool:
print("# no methods match", file=sys.stderr)
return 1
n = max(0, min(args.n, len(pool))) # clamp: never crash on a negative or oversized -n
picks = spread_sample(pool, n) if args.spread else random.sample(pool, n)
print(fmt_rows(picks, args.json))
return 0
if __name__ == "__main__":
sys.exit(main())
@@ -0,0 +1,228 @@
# /// script
# requires-python = ">=3.10"
# dependencies = ["pytest>=8.0"]
# ///
"""Tests for pick_methods.py.
Run: uv run scripts/tests/test_pick_methods.py
or: uv run --with pytest -m pytest scripts/tests/test_pick_methods.py
"""
import json
import random
import sys
from pathlib import Path
import pytest
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
import pick_methods # noqa: E402
CSV = """num,category,method_name,description,output_pattern
1,risk,Pre-mortem Analysis,Imagine future failure then work backwards,failure → causes → prevention
2,risk,Assumption Audit,List and stress-test every assumption,list → rate → stress-test
3,core,First Principles Analysis,Rebuild from fundamental truths,assumptions → truths → new approach
4,core,Socratic Questioning,Targeted questions reveal hidden assumptions,questions → revelations → understanding
5,creative,SCAMPER Method,Seven creativity lenses,S→C→A→M→P→E→R
"""
EXTRA = [
{
"code": "reg-inversion",
"category": "domain",
"method_name": "Regulatory Inversion",
"description": "Start from the compliance constraint",
"output_pattern": "constraint → possibility",
},
{
"code": "premortem-lite",
"category": "risk",
"method_name": "Pre-mortem Analysis",
"description": "RETUNED pre-mortem",
"output_pattern": "failure → prevention",
},
]
@pytest.fixture
def lib(tmp_path):
csv_path = tmp_path / "methods.csv"
csv_path.write_text(CSV, encoding="utf-8")
return csv_path
def rows(lib):
return pick_methods.load(lib)
# --- load / merge -----------------------------------------------------------
def test_load_all_fields_present(lib):
r = rows(lib)
assert len(r) == 5
assert r[0]["method_name"] == "Pre-mortem Analysis"
assert all(set(pick_methods.FIELDS) <= set(row) for row in r)
def test_load_extra_json_literal_and_file(tmp_path, lib):
literal = pick_methods.load_extra(json.dumps(EXTRA))
f = tmp_path / "extra.json"
f.write_text(json.dumps(EXTRA), encoding="utf-8")
from_file = pick_methods.load_extra(str(f))
assert literal == from_file
assert literal[0]["method_name"] == "Regulatory Inversion"
assert literal[0]["num"] == "" # missing fields normalize to empty
assert literal[0]["code"] == "reg-inversion" # code survives loading
def test_merge_extra_replaces_by_name_and_appends(lib):
merged = pick_methods.merge_extra(rows(lib), pick_methods.load_extra(json.dumps(EXTRA)))
assert len(merged) == 6 # 5 shipped, 1 replaced in place, 1 appended
premortem = next(r for r in merged if r["method_name"] == "Pre-mortem Analysis")
assert premortem["description"] == "RETUNED pre-mortem"
assert premortem["num"] == "1" # replacement inherits the shipped num
appended = next(r for r in merged if r["method_name"] == "Regulatory Inversion")
assert appended["num"] == "6" # appended extras get the next free num
assert dict(pick_methods.categories(merged))["domain"] == 1 # new category is first-class
def test_extras_are_addressable_by_num(lib):
merged = pick_methods.merge_extra(rows(lib), pick_methods.load_extra(json.dumps(EXTRA)))
found, missing = pick_methods.find(merged, ["6", "1"])
assert [r["method_name"] for r in found] == ["Regulatory Inversion", "Pre-mortem Analysis"]
assert missing == []
# --- categories / filter / find / exclude -----------------------------------
def test_categories_counts_sorted(lib):
assert pick_methods.categories(rows(lib)) == [("core", 2), ("creative", 1), ("risk", 2)]
def test_filter_is_case_insensitive(lib):
got = pick_methods.filter_cats(rows(lib), ["RISK"])
assert {r["method_name"] for r in got} == {"Pre-mortem Analysis", "Assumption Audit"}
def test_filter_none_returns_all(lib):
assert len(pick_methods.filter_cats(rows(lib), None)) == 5
def test_find_by_name_num_and_missing(lib):
found, missing = pick_methods.find(rows(lib), ["scamper method", "3", "Nope"])
assert [r["method_name"] for r in found] == ["SCAMPER Method", "First Principles Analysis"]
assert missing == ["Nope"]
def test_exclude_skips_named(lib):
got = pick_methods.exclude(rows(lib), ["pre-mortem analysis", "SCAMPER Method"])
assert {r["method_name"] for r in got} == {
"Assumption Audit", "First Principles Analysis", "Socratic Questioning",
}
# --- spread sampling ---------------------------------------------------------
def test_spread_hits_distinct_categories(lib):
for seed in range(20):
picks = pick_methods.spread_sample(rows(lib), 3, random.Random(seed))
assert len({r["category"] for r in picks}) == 3
def test_spread_wraps_when_categories_run_out(lib):
picks = pick_methods.spread_sample(rows(lib), 5, random.Random(0))
assert len(picks) == 5
assert len({r["method_name"] for r in picks}) == 5 # no duplicates
def test_spread_clamps_to_pool(lib):
assert len(pick_methods.spread_sample(rows(lib), 99, random.Random(0))) == 5
# --- CLI ---------------------------------------------------------------------
def run(args, lib, capsys):
code = pick_methods.main(["--file", str(lib), *args])
captured = capsys.readouterr()
return code, captured.out, captured.err
def test_cli_categories(lib, capsys):
code, out, _ = run(["categories"], lib, capsys)
assert code == 0
assert "risk\t2" in out
def test_cli_list_requires_scope(lib, capsys):
code, _, err = run(["list"], lib, capsys)
assert code == 2
assert "--category" in err
def test_cli_list_category_and_all(lib, capsys):
code, out, _ = run(["list", "--category", "core"], lib, capsys)
assert code == 0 and len(out.strip().splitlines()) == 2
assert "Socratic Questioning" in out and "SCAMPER" not in out
code, out, _ = run(["list", "--all"], lib, capsys)
assert code == 0 and "SCAMPER" in out
def test_cli_show_found_and_missing(lib, capsys):
code, out, err = run(["show", "Assumption Audit", "Ghost"], lib, capsys)
assert code == 0
assert "stress-test" in out
assert "not found: Ghost" in err
code, _, _ = run(["show", "Ghost"], lib, capsys)
assert code == 1
def test_cli_random_spread_exclude(lib, capsys):
code, out, _ = run(
["random", "-n", "3", "--spread", "--exclude", "SCAMPER Method"], lib, capsys
)
assert code == 0
lines = [ln for ln in out.strip().splitlines() if ln]
assert len(lines) == 3
assert "SCAMPER" not in out
def test_cli_random_clamps_and_empty_pool(lib, capsys):
code, out, _ = run(["random", "-n", "99"], lib, capsys)
assert code == 0 and len(out.strip().splitlines()) == 5
code, _, err = run(["random", "--category", "nope"], lib, capsys)
assert code == 1 and "no methods match" in err
def test_cli_extra_inline_json(lib, capsys):
code, out, _ = run(
["--extra", json.dumps(EXTRA), "list", "--category", "domain"], lib, capsys
)
assert code == 0 and "Regulatory Inversion" in out
def test_cli_bad_extra_and_missing_file(tmp_path, lib, capsys):
code, _, err = run(["--extra", str(tmp_path / "gone.json"), "categories"], lib, capsys)
assert code == 2 and "--extra" in err
code = pick_methods.main(["--file", str(tmp_path / "gone.csv"), "categories"])
assert code == 2
def test_cli_json_output(lib, capsys):
code, out, _ = run(["--json", "show", "1"], lib, capsys)
assert code == 0
data = json.loads(out)
assert data[0]["method_name"] == "Pre-mortem Analysis"
# --- shipped catalog integration ----------------------------------------------
def test_shipped_catalog_loads_clean():
shipped = pick_methods.DEFAULT_FILE
assert shipped.is_file(), f"shipped catalog missing: {shipped}"
r = pick_methods.load(shipped)
assert len(r) >= 60
for row in r:
assert row["category"] and row["method_name"] and row["description"], row
if __name__ == "__main__":
sys.exit(pytest.main([__file__, "-q"]))