bmad 6.11
This commit is contained in:
@@ -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"]))
|
||||
Reference in New Issue
Block a user