#!/usr/bin/env python3
# /// script
# requires-python = ">=3.10"
# ///
"""Serve the brainstorming technique library without loading it all into context.
The library is a CSV (category, technique_name, description, detail). `description`
is a short gist — enough to propose and run most techniques. `detail` is optional:
a path (relative to the CSV's directory) to a fuller instruction file for a technique
complex enough to warrant one. Only `show` resolves detail files, and only for the
technique asked for — so the heavy material never enters context until it is run.
Commands:
categories list category names + counts (the cheap entry point)
list --category C [...] the index (name + gist) for those categories
list --all the whole index at once — deliberate; large, avoid interactively
show NAME [NAME ...] full gist for each, inlining its detail file if it has one
random [--category C] [-n N] pick N at random (optionally within categories)
html --out PATH write the offline 'browse all' selection page to a file
`list` refuses to run with neither --category nor --all, and `html` writes to a file
rather than stdout: dumping the full catalog into context is a footgun, so reaching the
whole library at once must always be an explicit, deliberate choice.
`--extra PATH` merges a JSON overlay of additional techniques (customize.toml's
`additional_techniques`) into every command, so custom techniques and whole new
categories are first-class everywhere — including the browse page and category draws.
Default output is lean text for an LLM to read; pass --json for structured output.
"""
import argparse
import csv
import hashlib
import html
import json
import random
import sys
from pathlib import Path
DEFAULT_FILE = Path(__file__).resolve().parent.parent / "assets" / "brain-methods.csv"
FIELDS = ("category", "technique_name", "description", "detail", "provenance", "good_for", "audience")
# Optional columns beyond the original four — absent in older CSVs and in --extra
# overlays, so always read through .get/setdefault. `provenance` (classic|signature|
# playful) drives the "Proven & Professional" lead group; `good_for` (a |-separated
# list of goal tags) drives the browse page's goal filter; `audience` (solo|group|either)
# is advisory.
OPTIONAL_FIELDS = ("detail", "provenance", "good_for", "audience")
def load(file: Path) -> list[dict]:
with open(file, newline="", encoding="utf-8") as f:
rows = list(csv.DictReader(f))
for r in rows:
for k in OPTIONAL_FIELDS:
r.setdefault(k, "")
r[k] = (r.get(k) or "").strip()
return rows
def load_extra(file: Path) -> list[dict]:
"""Merge-in techniques from a JSON overlay — a list of
{category, technique_name, description[, detail]} objects. This is how
customize.toml's `additional_techniques` become first-class across *every*
subcommand (categories/list/random/show/html), so the browse page and
category draws include them too, not just the in-chat flows."""
data = json.loads(file.read_text(encoding="utf-8"))
rows = []
for item in data:
rows.append({
"category": str(item.get("category", "")).strip(),
"technique_name": str(item.get("technique_name", "")).strip(),
"description": str(item.get("description", "")).strip(),
"detail": str(item.get("detail") or "").strip(),
"provenance": str(item.get("provenance") or "").strip(),
"good_for": str(item.get("good_for") or "").strip(),
"audience": str(item.get("audience") or "").strip(),
})
return rows
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]]:
by_name = {r["technique_name"].lower(): r for r in rows}
found, missing = [], []
for n in names:
r = by_name.get(n.strip().lower())
(found if r else missing).append(r if r else n)
return found, missing
def resolve_detail(row: dict, csv_dir: Path) -> str | None:
"""Return the contents of a row's detail file, or None if there is no detail
(or the file is missing — a missing file is reported to stderr, not fatal)."""
if not row.get("detail"):
return None
path = (csv_dir / row["detail"]).resolve()
if not path.is_file():
print(f"# detail file not found for {row['technique_name']}: {row['detail']}", file=sys.stderr)
return None
return path.read_text(encoding="utf-8").strip()
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_list(rows: list[dict], as_json: bool) -> str:
if as_json:
return json.dumps([{k: r[k] for k in ("category", "technique_name", "description")} for r in rows])
return "\n".join(f"{r['category']}\t{r['technique_name']}\t{r['description']}" for r in rows)
def fmt_show(rows: list[dict], csv_dir: Path, as_json: bool) -> str:
if as_json:
out = []
for r in rows:
d = resolve_detail(r, csv_dir)
entry = {k: r[k] for k in ("category", "technique_name", "description")}
if d:
entry["detail"] = d
out.append(entry)
return json.dumps(out)
blocks = []
for r in rows:
block = f"## {r['technique_name']} [{r['category']}]\n{r['description']}"
d = resolve_detail(r, csv_dir)
if d:
block += f"\n\n{d}"
blocks.append(block)
return "\n\n".join(blocks)
def pretty(cat: str) -> str:
"""Turn a category slug (e.g. 'speculative_future') into a display name."""
return cat.replace("_", " ").replace("-", " ").title()
# --- card visuals: a crafted duotone icon + hue per category, plus a per-technique icon ---
# The hues and SVG glyphs are *data*, not logic: they live in the icon sidecar
# (assets/brain-icons.json) so the catalog's visuals can be edited without touching code.
# It maps category slug -> {hue, glyph} and technique name -> svg (inner markup, drawn in
# `currentColor` which the CSS sets to the category hue; the shared CHIP frame is added by
# the renderer). Anything missing falls back here — an unknown category gets a hash-derived
# hue + generic glyph, an unknown/not-yet-iconed technique a neutral mark — so custom
# catalogs always render.
ICON_FILE = DEFAULT_FILE.parent / "brain-icons.json"
CHIP = ''
_FALLBACK_GLYPH = (
''
''
''
)
_FALLBACK_TECH = (
''
)
def _load_icons(file: Path = ICON_FILE) -> tuple[dict, dict]:
"""Read the icon sidecar: (category slug -> {hue, glyph}, technique name -> svg).
A missing or malformed file is non-fatal — everything then uses the fallbacks below."""
try:
data = json.loads(file.read_text(encoding="utf-8"))
except (OSError, ValueError):
return {}, {}
return (data.get("categories") or {}), (data.get("techniques") or {})
_CATEGORY_STYLES, _TECH_ICONS = _load_icons()
def _hsl_hex(deg: int, s: float, lt: float) -> str:
import colorsys
r, g, b = colorsys.hls_to_rgb((deg % 360) / 360, lt, s)
return "#%02x%02x%02x" % (round(r * 255), round(g * 255), round(b * 255))
def category_style(cat: str) -> tuple[str, str]:
"""(hue, glyph markup) for a category — from the sidecar for the shipped set, derived for extras."""
style = _CATEGORY_STYLES.get(cat)
if style and style.get("hue"):
return style["hue"], style.get("glyph") or _FALLBACK_GLYPH
deg = int(hashlib.md5(cat.encode("utf-8")).hexdigest(), 16) % 360
return _hsl_hex(deg, 0.58, 0.52), _FALLBACK_GLYPH
def tech_icon(name: str) -> str:
"""The hand-picked line-icon for a specific technique (neutral mark if unknown)."""
return _TECH_ICONS.get(name, _FALLBACK_TECH)
SELECTOR_TEMPLATE = r"""
BMad Method Brainstorming Selection
BMad Method Brainstorming Selection
Compose your session, hit Copy prompt, and paste it back into the chat to begin. {{TOTAL}}
Facilitation
TechniquesPicked 0Random 0Invent 0AI picks 0Total 0 · 3–4 is the sweet spot
{{GOALBAR}}
Jump to
{{CHIPS}}
✓ Copied! Now paste it into the chat to start your session.
{{BODY}}
"""
# --- browse-page layout: a "Proven & Professional" lead group, then super-groups ----------
CLASSIC_GROUP = "Proven & Professional"
LEAD_HUE = "#3d4f73" # a dignified slate for the professional lead group
# Super-group order for the shipped categories. Categories not listed (e.g. user-added
# via --extra) render last under "More", alphabetically — so custom catalogs always show.
CATEGORY_GROUPS = (
("Structured & Analytical", ("structured", "deep")),
("Creative & Generative", ("creative", "biomimetic", "cultural", "speculative_future", "quantum")),
("Wild & Playful", ("wild", "absurdist", "theatrical", "constraint")),
("Introspective & Personal", ("introspective_delight", "collaborative")),
)
# Human labels for the `good_for` goal tags; this dict's order is the filter-bar order.
GOAL_LABELS = {
"feature": "Build a feature",
"novel": "Novel concept",
"strategy": "Strategy",
"planning": "Planning",
"diagnosis": "Diagnose",
"personal": "Personal / life",
"unstuck": "Get unstuck",
}
def _good_for_label(good: str) -> str:
parts = [GOAL_LABELS.get(g, g) for g in good.split("|") if g]
return ("Great for: " + " · ".join(parts)) if parts else ""
def _svg(inner: str) -> str:
return f''
def _card(r: dict, lead: bool = False) -> str:
"""One technique card. `lead=True` cards live in the cross-cutting professional group;
they carry their own category hue (inline --c) and data-lead so selection can de-dupe."""
name = html.escape(r["technique_name"])
desc = html.escape(r["description"])
hue, glyph = category_style(r["category"])
disp_cat = html.escape(pretty(r["category"]))
good = html.escape(r.get("good_for", ""))
prov = html.escape(r.get("provenance", ""))
style = f' style="--c:{hue}"' if lead else ""
lead_attr = ' data-lead="1"' if lead else ""
gf = _good_for_label(r.get("good_for", ""))
gf_html = f'{html.escape(gf)}' if gf else ""
return (
f''
)
def _invent_card(disp_cat: str, glyph: str) -> str:
"""A dashed 'invent on the fly, in this category's spirit' card appended to each section."""
return (
f''
)
def html_doc(rows: list[dict]) -> str:
"""Render the self-contained 'browse all techniques' selection page from the catalog.
Deterministic ordering so the shipped asset can be snapshot-tested against the CSV:
a cross-cutting "Proven & Professional" lead group (every `classic`-tagged row), then
the categories in fixed super-group order, then any unlisted/custom categories under
"More" alphabetically. Techniques render in file order within a category. A `classic`
row appears both in the lead group and its home category; the page de-dupes on select.
"""
groups: dict[str, list[dict]] = {}
for r in rows:
groups.setdefault(r["category"], []).append(r)
body: list[str] = []
chips: list[str] = []
def add_section(cat: str) -> None:
hue, glyph = category_style(cat)
disp = html.escape(pretty(cat))
cards = [_card(r) for r in groups[cat]]
cards.append(_invent_card(disp, glyph))
chips.append(f'')
body.append(
f'
{disp}{len(groups[cat])}
'
f'
{"".join(cards)}
'
)
# 1) lead group — every classic-tagged technique, cross-category (no invent card here)
classics = [r for r in rows if r.get("provenance", "").lower() == "classic"]
if classics:
disp = html.escape(CLASSIC_GROUP)
lead_cards = "".join(_card(r, lead=True) for r in classics)
chips.append(f'')
body.append(
f'
{disp}{len(classics)}
'
f'
{lead_cards}
'
)
# 2) shipped categories, in super-group order
placed = set()
for group_title, cats in CATEGORY_GROUPS:
present = [c for c in cats if c in groups]
if not present:
continue
hue, _ = category_style(present[0])
body.append(f'
{html.escape(group_title)}
')
for c in present:
add_section(c)
placed.add(c)
# 3) leftover (custom / --extra) categories, alphabetically
leftover = sorted(c for c in groups if c not in placed)
if leftover:
body.append('
More
')
for c in leftover:
add_section(c)
# goal-affinity filter bar — only if the catalog actually carries good_for tags
present_goals: set[str] = set()
for r in rows:
for g in (r.get("good_for", "") or "").split("|"):
if g:
present_goals.add(g)
goalbar = ""
if present_goals:
ordered = [g for g in GOAL_LABELS if g in present_goals] + sorted(present_goals - set(GOAL_LABELS))
gchips = "".join(
f''
for g in ordered
)
goalbar = f'
Great for
{gchips}
'
total = html.escape(f"{len(rows)} techniques across {len(groups)} categories.")
return (
SELECTOR_TEMPLATE.replace("{{BODY}}", "\n".join(body))
.replace("{{CHIPS}}", "".join(chips))
.replace("{{GOALBAR}}", goalbar)
.replace("{{TOTAL}}", total)
)
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--file", type=Path, default=DEFAULT_FILE, help="technique CSV (default: sibling assets/brain-methods.csv)")
p.add_argument("--extra", type=Path, help="JSON overlay of additional techniques (customize.toml additional_techniques), merged into every command")
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: category/name/gist (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 gist + detail file for named techniques")
ps.add_argument("names", nargs="+")
pr = sub.add_parser("random", help="pick techniques at random")
pr.add_argument("--category", action="append", help="restrict to a category (repeatable)")
pr.add_argument("-n", type=int, default=1, help="how many (default 1)")
ph = sub.add_parser("html", help="write the offline 'browse all' selection page")
ph.add_argument("--out", help="file to write the page to (required; never prints the catalog)")
args = p.parse_args(argv)
if not args.file.is_file():
print(f"error: technique file not found: {args.file}", file=sys.stderr)
return 2
rows = load(args.file)
if args.extra:
if not args.extra.is_file():
print(f"error: --extra file not found: {args.extra}", file=sys.stderr)
return 2
rows += load_extra(args.extra)
csv_dir = args.file.resolve().parent
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_list(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_show(found, csv_dir, args.json))
elif args.cmd == "random":
pool = filter_cats(rows, args.category)
if not pool:
print("# no techniques match", file=sys.stderr)
return 1
n = max(0, min(args.n, len(pool))) # clamp: never crash on a negative or oversized -n
print(fmt_list(random.sample(pool, n), args.json))
elif args.cmd == "html":
if not args.out:
print(
"error: `html` needs --out PATH — it writes the selection page to a file and "
"never prints the catalog to stdout (which would defeat the point).",
file=sys.stderr,
)
return 2
out = Path(args.out)
out.parent.mkdir(parents=True, exist_ok=True)
out.write_text(html_doc(rows), encoding="utf-8")
print(f"wrote {out} ({len(rows)} techniques, {len(categories(rows))} categories)")
return 0
if __name__ == "__main__":
sys.exit(main())