Skills › BMAD Method › bmad-advanced-elicitation › pick_methods.py
pick_methods.py
246 lines · skills/bmad-advanced-elicitation/scripts/pick_methods.py at bd70c64
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""Serve the elicitation method catalog without loading it all into context.
7The catalog is a CSV (num, category, method_name, description, output_pattern).
8`description` is a one-line gist — enough to run the method; `output_pattern` is
9a flexible flow guide (e.g. "assumptions → truths → new approach").
11Commands:
12 categories list category names + counts (the cheap entry point)
13 list --category C [...] the index (num/category/name/gist) for those categories
14 list --all the whole catalog at once — deliberate; large, avoid interactively
15 show NAME_OR_NUM [...] full row for each method, matched by name or num
16 random [-n N] [--category C ...] [--exclude NAME ...] [--spread]
17 draw N at random; --spread forces category diversity
18 (at most one per category until categories run out) —
19 the reshuffle draw; --exclude skips already-shown methods
21`list` refuses to run with neither --category nor --all: dumping the full catalog
22into context must always be an explicit, deliberate choice.
24`--extra SPEC` merges additional methods (customize.toml's `additional_methods`)
25into every command. SPEC is either a JSON array literal (starts with `[`) or a
26path to a JSON file; each item is {code, category, method_name, description,
27output_pattern}. An extra whose method_name matches a catalog row
28(case-insensitive) REPLACES it and keeps that row's num — retune a shipped
29method; others append and get the next free nums, so new methods and whole new
30categories are first-class and number-addressable everywhere.
32Default output is lean tab-separated text for an LLM to read; --json for structured.
33"""
35import argparse
36import csv
37import json
38import random
39import sys
40from pathlib import Path
42DEFAULT_FILE = Path(__file__).resolve().parent.parent / "assets" / "methods.csv"
43FIELDS = ("num", "category", "method_name", "description", "output_pattern")
44REQUIRED_FIELDS = ("category", "method_name", "description", "output_pattern")
47def load(file: Path) -> list[dict]:
48 # utf-8-sig: tolerate BOM-prefixed catalogs (Excel "CSV UTF-8", Notepad)
49 with open(file, newline="", encoding="utf-8-sig") as f:
50 rows = list(csv.DictReader(f))
51 for r in rows:
52 for k in FIELDS:
53 r.setdefault(k, "")
54 r[k] = (r.get(k) or "").strip()
55 return rows
58def load_extra(spec: str) -> list[dict]:
59 """Parse the --extra overlay: a JSON array literal or a path to a JSON file."""
60 text = spec if spec.lstrip().startswith("[") else Path(spec).read_text(encoding="utf-8-sig")
61 data = json.loads(text)
62 if not isinstance(data, list):
63 raise ValueError("--extra must be a JSON array of objects")
64 rows = []
65 for n, item in enumerate(data, 1):
66 if not isinstance(item, dict):
67 raise ValueError(f"each --extra entry must be a JSON object, got: {item!r}")
68 row = {k: str(item.get(k) or "").strip() for k in FIELDS}
69 row["code"] = str(item.get("code") or "").strip() # kept for traceability
70 for field in REQUIRED_FIELDS:
71 if not row[field]:
72 name = row["method_name"] or row["code"] or "unnamed"
73 raise ValueError(f"--extra entry {n} ({name}) is missing {field}")
74 rows.append(row)
75 return rows
78def merge_extra(rows: list[dict], extras: list[dict]) -> list[dict]:
79 """Extras replace a catalog row with the same method_name (case-insensitive),
80 otherwise append — so overrides can retune shipped methods or grow the catalog.
81 A replacement inherits the shipped row's num; appended extras get the next
82 free nums, so every merged method stays addressable by number."""
83 merged = list(rows)
84 index = {r["method_name"].lower(): i for i, r in enumerate(merged)}
85 for e in extras:
86 key = e["method_name"].lower()
87 if key in index:
88 e = dict(e)
89 e["num"] = e["num"] or merged[index[key]]["num"]
90 merged[index[key]] = e
91 else:
92 index[key] = len(merged)
93 merged.append(dict(e))
94 next_num = max((int(r["num"]) for r in merged if r["num"].isdigit()), default=0) + 1
95 seen = {}
96 for r in merged:
97 if not r["num"]:
98 r["num"] = str(next_num)
99 next_num += 1
100 if r["num"] in seen:
101 raise ValueError(f"num {r['num']} is used by both {seen[r['num']]} and {r['method_name']}")
102 seen[r["num"]] = r["method_name"]
103 return merged
106def categories(rows: list[dict]) -> list[tuple[str, int]]:
107 counts: dict[str, int] = {}
108 for r in rows:
109 counts[r["category"]] = counts.get(r["category"], 0) + 1
110 return sorted(counts.items())
113def filter_cats(rows: list[dict], cats: list[str] | None) -> list[dict]:
114 if not cats:
115 return rows
116 wanted = {c.lower() for c in cats}
117 return [r for r in rows if r["category"].lower() in wanted]
120def find(rows: list[dict], names: list[str]) -> tuple[list[dict], list[str]]:
121 """Match each query by method_name or by num, case-insensitively."""
122 by_key: dict[str, dict] = {}
123 for r in rows:
124 by_key[r["method_name"].lower()] = r
125 if r["num"]:
126 by_key.setdefault(r["num"], r)
127 found, missing = [], []
128 for n in names:
129 r = by_key.get(n.strip().lower())
130 (found if r else missing).append(r if r else n)
131 return found, missing
134def exclude(rows: list[dict], names: list[str] | None) -> list[dict]:
135 if not names:
136 return rows
137 skip = {n.strip().lower() for n in names}
138 return [r for r in rows if r["method_name"].lower() not in skip]
141def spread_sample(rows: list[dict], n: int, rng: random.Random | None = None) -> list[dict]:
142 """Draw n methods with maximum category diversity: shuffle the categories,
143 take one random method per category round-robin, wrapping only when there
144 are fewer categories than picks."""
145 rng = rng or random
146 by_cat: dict[str, list[dict]] = {}
147 for r in rows:
148 by_cat.setdefault(r["category"], []).append(r)
149 buckets = list(by_cat.values())
150 rng.shuffle(buckets)
151 for b in buckets:
152 rng.shuffle(b)
153 out: list[dict] = []
154 while buckets and len(out) < n:
155 exhausted = []
156 for b in buckets:
157 if len(out) >= n:
158 break
159 out.append(b.pop())
160 if not b:
161 exhausted.append(b)
162 buckets = [b for b in buckets if b not in exhausted]
163 return out
166def fmt_categories(cats: list[tuple[str, int]], as_json: bool) -> str:
167 if as_json:
168 return json.dumps([{"category": c, "count": n} for c, n in cats])
169 return "\n".join(f"{c}\t{n}" for c, n in cats)
172def fmt_rows(rows: list[dict], as_json: bool) -> str:
173 if as_json:
174 return json.dumps([{k: r[k] for k in FIELDS} for r in rows])
175 return "\n".join(
176 f"{r['num']}\t{r['category']}\t{r['method_name']}\t{r['description']}\t{r['output_pattern']}" for r in rows
177 )
180def main(argv: list[str] | None = None) -> int:
181 if hasattr(sys.stdout, "reconfigure"):
182 sys.stdout.reconfigure(encoding="utf-8") # catalog rows contain →; don't die on locale code pages
183 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
184 p.add_argument("--file", type=Path, default=DEFAULT_FILE, help="method CSV (default: sibling assets/methods.csv)")
185 p.add_argument("--extra", help="additional methods: a JSON array literal or a path to a JSON file")
186 p.add_argument("--json", action="store_true", help="emit structured JSON instead of lean text")
187 sub = p.add_subparsers(dest="cmd", required=True)
188 sub.add_parser("categories", help="list category names + counts")
189 pl = sub.add_parser("list", help="the index for chosen categories (needs --category or --all)")
190 pl.add_argument("--category", action="append", help="filter to a category (repeatable)")
191 pl.add_argument("--all", action="store_true", help="dump the entire catalog (deliberate; large)")
192 ps = sub.add_parser("show", help="full row for each named method")
193 ps.add_argument("names", nargs="+", help="method names or nums")
194 pr = sub.add_parser("random", help="draw methods at random")
195 pr.add_argument("-n", type=int, default=1, help="how many (default 1)")
196 pr.add_argument("--category", action="append", help="restrict to a category (repeatable)")
197 pr.add_argument("--exclude", action="append", help="method name to skip (repeatable) — e.g. already shown")
198 pr.add_argument("--spread", action="store_true", help="force category diversity across the draw")
199 args = p.parse_args(argv)
201 if not args.file.is_file():
202 print(f"error: method file not found: {args.file}", file=sys.stderr)
203 return 2
204 rows = load(args.file)
205 if args.extra:
206 try:
207 rows = merge_extra(rows, load_extra(args.extra))
208 except (OSError, ValueError) as e:
209 print(f"error: could not read --extra: {e}", file=sys.stderr)
210 return 2
212 if args.cmd == "categories":
213 print(fmt_categories(categories(rows), args.json))
214 elif args.cmd == "list":
215 if not args.category and not args.all:
216 print(
217 "error: `list` needs --category (one or more) — or --all to dump the whole "
218 "catalog on purpose. Use `categories` for the cheap map, or `random` to draw blind.",
219 file=sys.stderr,
220 )
221 return 2
222 print(fmt_rows(filter_cats(rows, args.category), args.json))
223 elif args.cmd == "show":
224 found, missing = find(rows, args.names)
225 for m in missing:
226 print(f"# not found: {m}", file=sys.stderr)
227 if not found:
228 return 1
229 print(fmt_rows(found, args.json))
230 elif args.cmd == "random":
231 pool = exclude(filter_cats(rows, args.category), args.exclude)
232 if not pool:
233 print("# no methods match", file=sys.stderr)
234 return 1
235 n = max(0, min(args.n, len(pool))) # clamp: never crash on a negative or oversized -n
236 picks = spread_sample(pool, n) if args.spread else random.sample(pool, n)
237 print(fmt_rows(picks, args.json))
238 return 0
241if __name__ == "__main__":
242 if sys.platform == "win32":
243 # Piped output on Windows defaults to a legacy code page, not UTF-8.
244 sys.stdout.reconfigure(encoding="utf-8")
245 sys.stderr.reconfigure(encoding="utf-8")
246 sys.exit(main())