bmad-method 6.8.1-next.0 → 6.8.1-next.2
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 +1 -1
- package/src/bmm-skills/4-implementation/bmad-code-review/SKILL.md +3 -0
- package/src/bmm-skills/4-implementation/bmad-create-story/SKILL.md +3 -0
- package/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md +3 -0
- package/src/core-skills/bmad-brainstorming/SKILL.md +78 -2
- package/src/core-skills/bmad-brainstorming/analysis/catalog-analysis.md +239 -0
- package/src/core-skills/bmad-brainstorming/analysis/method-matrix.csv +109 -0
- package/src/core-skills/bmad-brainstorming/assets/brain-icons.json +166 -0
- package/src/core-skills/bmad-brainstorming/assets/brain-methods.csv +109 -0
- package/src/core-skills/bmad-brainstorming/assets/brain-selector.html +326 -0
- package/src/core-skills/bmad-brainstorming/customize.toml +84 -0
- package/src/core-skills/bmad-brainstorming/references/converge.md +24 -0
- package/src/core-skills/bmad-brainstorming/references/finalize.md +26 -0
- package/src/core-skills/bmad-brainstorming/references/headless.md +54 -0
- package/src/core-skills/bmad-brainstorming/references/in-chat-techniques.md +18 -0
- package/src/core-skills/bmad-brainstorming/references/mode-autonomous.md +10 -0
- package/src/core-skills/bmad-brainstorming/references/mode-facilitator.md +11 -0
- package/src/core-skills/bmad-brainstorming/references/mode-partner.md +16 -0
- package/src/core-skills/bmad-brainstorming/references/resume.md +5 -0
- package/src/core-skills/bmad-brainstorming/scripts/brain.py +740 -0
- package/src/core-skills/bmad-brainstorming/scripts/memlog.py +202 -0
- package/src/core-skills/bmad-brainstorming/scripts/tests/test_brain.py +217 -0
- package/src/core-skills/bmad-brainstorming/scripts/tests/test_memlog.py +265 -0
- package/src/core-skills/bmad-brainstorming/brain-methods.csv +0 -62
- package/src/core-skills/bmad-brainstorming/steps/step-01-session-setup.md +0 -214
- package/src/core-skills/bmad-brainstorming/steps/step-01b-continue.md +0 -124
- package/src/core-skills/bmad-brainstorming/steps/step-02a-user-selected.md +0 -229
- package/src/core-skills/bmad-brainstorming/steps/step-02b-ai-recommended.md +0 -239
- package/src/core-skills/bmad-brainstorming/steps/step-02c-random-selection.md +0 -211
- package/src/core-skills/bmad-brainstorming/steps/step-02d-progressive-flow.md +0 -266
- package/src/core-skills/bmad-brainstorming/steps/step-03-technique-execution.md +0 -403
- package/src/core-skills/bmad-brainstorming/steps/step-04-idea-organization.md +0 -305
- package/src/core-skills/bmad-brainstorming/template.md +0 -15
- package/src/core-skills/bmad-brainstorming/workflow.md +0 -53
|
@@ -0,0 +1,740 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# ///
|
|
5
|
+
"""Serve the brainstorming technique library without loading it all into context.
|
|
6
|
+
|
|
7
|
+
The library is a CSV (category, technique_name, description, detail). `description`
|
|
8
|
+
is a short gist — enough to propose and run most techniques. `detail` is optional:
|
|
9
|
+
a path (relative to the CSV's directory) to a fuller instruction file for a technique
|
|
10
|
+
complex enough to warrant one. Only `show` resolves detail files, and only for the
|
|
11
|
+
technique asked for — so the heavy material never enters context until it is run.
|
|
12
|
+
|
|
13
|
+
Commands:
|
|
14
|
+
categories list category names + counts (the cheap entry point)
|
|
15
|
+
list --category C [...] the index (name + gist) for those categories
|
|
16
|
+
list --all the whole index at once — deliberate; large, avoid interactively
|
|
17
|
+
show NAME [NAME ...] full gist for each, inlining its detail file if it has one
|
|
18
|
+
random [--category C] [-n N] pick N at random (optionally within categories)
|
|
19
|
+
html --out PATH write the offline 'browse all' selection page to a file
|
|
20
|
+
|
|
21
|
+
`list` refuses to run with neither --category nor --all, and `html` writes to a file
|
|
22
|
+
rather than stdout: dumping the full catalog into context is a footgun, so reaching the
|
|
23
|
+
whole library at once must always be an explicit, deliberate choice.
|
|
24
|
+
|
|
25
|
+
`--extra PATH` merges a JSON overlay of additional techniques (customize.toml's
|
|
26
|
+
`additional_techniques`) into every command, so custom techniques and whole new
|
|
27
|
+
categories are first-class everywhere — including the browse page and category draws.
|
|
28
|
+
|
|
29
|
+
Default output is lean text for an LLM to read; pass --json for structured output.
|
|
30
|
+
"""
|
|
31
|
+
import argparse
|
|
32
|
+
import csv
|
|
33
|
+
import hashlib
|
|
34
|
+
import html
|
|
35
|
+
import json
|
|
36
|
+
import random
|
|
37
|
+
import sys
|
|
38
|
+
from pathlib import Path
|
|
39
|
+
|
|
40
|
+
DEFAULT_FILE = Path(__file__).resolve().parent.parent / "assets" / "brain-methods.csv"
|
|
41
|
+
FIELDS = ("category", "technique_name", "description", "detail", "provenance", "good_for", "audience")
|
|
42
|
+
# Optional columns beyond the original four — absent in older CSVs and in --extra
|
|
43
|
+
# overlays, so always read through .get/setdefault. `provenance` (classic|signature|
|
|
44
|
+
# playful) drives the "Proven & Professional" lead group; `good_for` (a |-separated
|
|
45
|
+
# list of goal tags) drives the browse page's goal filter; `audience` (solo|group|either)
|
|
46
|
+
# is advisory.
|
|
47
|
+
OPTIONAL_FIELDS = ("detail", "provenance", "good_for", "audience")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def load(file: Path) -> list[dict]:
|
|
51
|
+
with open(file, newline="", encoding="utf-8") as f:
|
|
52
|
+
rows = list(csv.DictReader(f))
|
|
53
|
+
for r in rows:
|
|
54
|
+
for k in OPTIONAL_FIELDS:
|
|
55
|
+
r.setdefault(k, "")
|
|
56
|
+
r[k] = (r.get(k) or "").strip()
|
|
57
|
+
return rows
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def load_extra(file: Path) -> list[dict]:
|
|
61
|
+
"""Merge-in techniques from a JSON overlay — a list of
|
|
62
|
+
{category, technique_name, description[, detail]} objects. This is how
|
|
63
|
+
customize.toml's `additional_techniques` become first-class across *every*
|
|
64
|
+
subcommand (categories/list/random/show/html), so the browse page and
|
|
65
|
+
category draws include them too, not just the in-chat flows."""
|
|
66
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
67
|
+
rows = []
|
|
68
|
+
for item in data:
|
|
69
|
+
rows.append({
|
|
70
|
+
"category": str(item.get("category", "")).strip(),
|
|
71
|
+
"technique_name": str(item.get("technique_name", "")).strip(),
|
|
72
|
+
"description": str(item.get("description", "")).strip(),
|
|
73
|
+
"detail": str(item.get("detail") or "").strip(),
|
|
74
|
+
"provenance": str(item.get("provenance") or "").strip(),
|
|
75
|
+
"good_for": str(item.get("good_for") or "").strip(),
|
|
76
|
+
"audience": str(item.get("audience") or "").strip(),
|
|
77
|
+
})
|
|
78
|
+
return rows
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def categories(rows: list[dict]) -> list[tuple[str, int]]:
|
|
82
|
+
counts: dict[str, int] = {}
|
|
83
|
+
for r in rows:
|
|
84
|
+
counts[r["category"]] = counts.get(r["category"], 0) + 1
|
|
85
|
+
return sorted(counts.items())
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def filter_cats(rows: list[dict], cats: list[str] | None) -> list[dict]:
|
|
89
|
+
if not cats:
|
|
90
|
+
return rows
|
|
91
|
+
wanted = {c.lower() for c in cats}
|
|
92
|
+
return [r for r in rows if r["category"].lower() in wanted]
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def find(rows: list[dict], names: list[str]) -> tuple[list[dict], list[str]]:
|
|
96
|
+
by_name = {r["technique_name"].lower(): r for r in rows}
|
|
97
|
+
found, missing = [], []
|
|
98
|
+
for n in names:
|
|
99
|
+
r = by_name.get(n.strip().lower())
|
|
100
|
+
(found if r else missing).append(r if r else n)
|
|
101
|
+
return found, missing
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def resolve_detail(row: dict, csv_dir: Path) -> str | None:
|
|
105
|
+
"""Return the contents of a row's detail file, or None if there is no detail
|
|
106
|
+
(or the file is missing — a missing file is reported to stderr, not fatal)."""
|
|
107
|
+
if not row.get("detail"):
|
|
108
|
+
return None
|
|
109
|
+
path = (csv_dir / row["detail"]).resolve()
|
|
110
|
+
if not path.is_file():
|
|
111
|
+
print(f"# detail file not found for {row['technique_name']}: {row['detail']}", file=sys.stderr)
|
|
112
|
+
return None
|
|
113
|
+
return path.read_text(encoding="utf-8").strip()
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def fmt_categories(cats: list[tuple[str, int]], as_json: bool) -> str:
|
|
117
|
+
if as_json:
|
|
118
|
+
return json.dumps([{"category": c, "count": n} for c, n in cats])
|
|
119
|
+
return "\n".join(f"{c}\t{n}" for c, n in cats)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def fmt_list(rows: list[dict], as_json: bool) -> str:
|
|
123
|
+
if as_json:
|
|
124
|
+
return json.dumps([{k: r[k] for k in ("category", "technique_name", "description")} for r in rows])
|
|
125
|
+
return "\n".join(f"{r['category']}\t{r['technique_name']}\t{r['description']}" for r in rows)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fmt_show(rows: list[dict], csv_dir: Path, as_json: bool) -> str:
|
|
129
|
+
if as_json:
|
|
130
|
+
out = []
|
|
131
|
+
for r in rows:
|
|
132
|
+
d = resolve_detail(r, csv_dir)
|
|
133
|
+
entry = {k: r[k] for k in ("category", "technique_name", "description")}
|
|
134
|
+
if d:
|
|
135
|
+
entry["detail"] = d
|
|
136
|
+
out.append(entry)
|
|
137
|
+
return json.dumps(out)
|
|
138
|
+
blocks = []
|
|
139
|
+
for r in rows:
|
|
140
|
+
block = f"## {r['technique_name']} [{r['category']}]\n{r['description']}"
|
|
141
|
+
d = resolve_detail(r, csv_dir)
|
|
142
|
+
if d:
|
|
143
|
+
block += f"\n\n{d}"
|
|
144
|
+
blocks.append(block)
|
|
145
|
+
return "\n\n".join(blocks)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def pretty(cat: str) -> str:
|
|
149
|
+
"""Turn a category slug (e.g. 'speculative_future') into a display name."""
|
|
150
|
+
return cat.replace("_", " ").replace("-", " ").title()
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --- card visuals: a crafted duotone icon + hue per category, plus a per-technique icon ---
|
|
154
|
+
# The hues and SVG glyphs are *data*, not logic: they live in the icon sidecar
|
|
155
|
+
# (assets/brain-icons.json) so the catalog's visuals can be edited without touching code.
|
|
156
|
+
# It maps category slug -> {hue, glyph} and technique name -> svg (inner markup, drawn in
|
|
157
|
+
# `currentColor` which the CSS sets to the category hue; the shared CHIP frame is added by
|
|
158
|
+
# the renderer). Anything missing falls back here — an unknown category gets a hash-derived
|
|
159
|
+
# hue + generic glyph, an unknown/not-yet-iconed technique a neutral mark — so custom
|
|
160
|
+
# catalogs always render.
|
|
161
|
+
|
|
162
|
+
ICON_FILE = DEFAULT_FILE.parent / "brain-icons.json"
|
|
163
|
+
|
|
164
|
+
CHIP = '<rect x="1.5" y="1.5" width="41" height="41" rx="12" fill="currentColor" fill-opacity="0.12"/>'
|
|
165
|
+
|
|
166
|
+
_FALLBACK_GLYPH = (
|
|
167
|
+
'<circle cx="22" cy="22" r="11" fill="currentColor" fill-opacity="0.16"/>'
|
|
168
|
+
'<circle cx="22" cy="22" r="11" stroke="currentColor" stroke-width="1.6" fill="none"/>'
|
|
169
|
+
'<circle cx="22" cy="22" r="3.4" fill="currentColor"/>'
|
|
170
|
+
)
|
|
171
|
+
_FALLBACK_TECH = (
|
|
172
|
+
'<rect x="15" y="15" width="14" height="14" rx="2.5" transform="rotate(45 22 22)" '
|
|
173
|
+
'fill="none" stroke="currentColor" stroke-width="2"/><circle cx="22" cy="22" r="2.4" fill="currentColor"/>'
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _load_icons(file: Path = ICON_FILE) -> tuple[dict, dict]:
|
|
178
|
+
"""Read the icon sidecar: (category slug -> {hue, glyph}, technique name -> svg).
|
|
179
|
+
A missing or malformed file is non-fatal — everything then uses the fallbacks below."""
|
|
180
|
+
try:
|
|
181
|
+
data = json.loads(file.read_text(encoding="utf-8"))
|
|
182
|
+
except (OSError, ValueError):
|
|
183
|
+
return {}, {}
|
|
184
|
+
return (data.get("categories") or {}), (data.get("techniques") or {})
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
_CATEGORY_STYLES, _TECH_ICONS = _load_icons()
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _hsl_hex(deg: int, s: float, lt: float) -> str:
|
|
191
|
+
import colorsys
|
|
192
|
+
|
|
193
|
+
r, g, b = colorsys.hls_to_rgb((deg % 360) / 360, lt, s)
|
|
194
|
+
return "#%02x%02x%02x" % (round(r * 255), round(g * 255), round(b * 255))
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def category_style(cat: str) -> tuple[str, str]:
|
|
198
|
+
"""(hue, glyph markup) for a category — from the sidecar for the shipped set, derived for extras."""
|
|
199
|
+
style = _CATEGORY_STYLES.get(cat)
|
|
200
|
+
if style and style.get("hue"):
|
|
201
|
+
return style["hue"], style.get("glyph") or _FALLBACK_GLYPH
|
|
202
|
+
deg = int(hashlib.md5(cat.encode("utf-8")).hexdigest(), 16) % 360
|
|
203
|
+
return _hsl_hex(deg, 0.58, 0.52), _FALLBACK_GLYPH
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def tech_icon(name: str) -> str:
|
|
207
|
+
"""The hand-picked line-icon for a specific technique (neutral mark if unknown)."""
|
|
208
|
+
return _TECH_ICONS.get(name, _FALLBACK_TECH)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
SELECTOR_TEMPLATE = r"""<!DOCTYPE html>
|
|
212
|
+
<html lang="en">
|
|
213
|
+
<head>
|
|
214
|
+
<meta charset="utf-8">
|
|
215
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
216
|
+
<title>BMad Method Brainstorming Selection</title>
|
|
217
|
+
<script>
|
|
218
|
+
/* set the theme before first paint so there's no light-mode flash */
|
|
219
|
+
(function(){ try {
|
|
220
|
+
var t = localStorage.getItem('bmad-theme');
|
|
221
|
+
if (!t) { t = (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) ? 'dark' : 'light'; }
|
|
222
|
+
document.documentElement.setAttribute('data-theme', t);
|
|
223
|
+
} catch(e){} })();
|
|
224
|
+
</script>
|
|
225
|
+
<style>
|
|
226
|
+
:root {
|
|
227
|
+
--bg:#f6f7fb; --surface:#fff; --ink:#1c1e2b; --muted:#6b7080;
|
|
228
|
+
--accent:#5b4bdc; --accent-ink:#5b4bdc; --warn:#c0561f;
|
|
229
|
+
--line:#e6e8f0; --control:#eef0f7; --control2:#f1f2f8; --raised:#fff;
|
|
230
|
+
--cnt:#b9bdce; --foot:#aeb2c4; --shadow:rgba(20,20,50,.06);
|
|
231
|
+
}
|
|
232
|
+
:root[data-theme="dark"] {
|
|
233
|
+
--bg:#0f1117; --surface:#171a23; --ink:#e7e9f2; --muted:#9aa0b4;
|
|
234
|
+
--accent:#6d5cf0; --accent-ink:#a99bff; --warn:#e08a4a;
|
|
235
|
+
--line:#2a2f3e; --control:#222634; --control2:#1d212d; --raised:#2c3242;
|
|
236
|
+
--cnt:#5a6076; --foot:#5a6076; --shadow:rgba(0,0,0,.45);
|
|
237
|
+
}
|
|
238
|
+
/* lift the category hue toward white on dark surfaces so deep hues stay legible */
|
|
239
|
+
:root[data-theme="dark"] section > h2 { color:color-mix(in srgb, var(--c) 62%, #fff); }
|
|
240
|
+
:root[data-theme="dark"] .tech .ico { color:color-mix(in srgb, var(--c) 68%, #fff); }
|
|
241
|
+
:root[data-theme="dark"] label.tech:has(input:checked) { border-color:color-mix(in srgb, var(--c) 60%, #fff); }
|
|
242
|
+
.titlerow { display:flex; align-items:flex-start; justify-content:space-between; gap:12px; }
|
|
243
|
+
.themebtn { flex:none; width:36px; height:36px; border-radius:9px; background:var(--control); color:var(--ink); font-size:17px; line-height:1; display:inline-flex; align-items:center; justify-content:center; }
|
|
244
|
+
.themebtn:hover { background:var(--raised); }
|
|
245
|
+
* { box-sizing:border-box; }
|
|
246
|
+
body { margin:0; font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Arial,sans-serif; background:var(--bg); color:var(--ink); }
|
|
247
|
+
header { position:sticky; top:0; z-index:5; background:var(--surface); padding:20px 0 12px; border-bottom:1px solid var(--line); box-shadow:0 2px 12px var(--shadow); }
|
|
248
|
+
.hwrap { max-width:1120px; margin:0 auto; padding:0 24px; } /* align header content with the card column on wide screens */
|
|
249
|
+
h1 { margin:0 0 4px; font-size:24px; letter-spacing:-.02em; }
|
|
250
|
+
.sub { margin:0 0 12px; color:var(--muted); font-size:14px; max-width:74ch; }
|
|
251
|
+
button { font:inherit; border:0; border-radius:8px; cursor:pointer; }
|
|
252
|
+
.composer { display:flex; flex-direction:column; gap:9px; margin:6px 0 12px; }
|
|
253
|
+
.grp { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
|
254
|
+
.glabel { font-size:11px; text-transform:uppercase; letter-spacing:.07em; color:var(--muted); min-width:74px; }
|
|
255
|
+
.modes { display:inline-flex; background:var(--control); border-radius:9px; padding:3px; gap:2px; }
|
|
256
|
+
.mode { padding:7px 13px; font-size:14px; font-weight:600; color:var(--muted); background:transparent; }
|
|
257
|
+
.mode.on { background:var(--raised); color:var(--accent-ink); box-shadow:0 1px 3px var(--shadow); }
|
|
258
|
+
.modehint { flex:1 1 240px; min-width:0; font-size:13px; color:var(--muted); font-style:italic; }
|
|
259
|
+
.pill { font-size:13px; color:var(--muted); background:var(--control); padding:6px 12px; border-radius:20px; }
|
|
260
|
+
.pill b { color:var(--accent-ink); }
|
|
261
|
+
.step { display:inline-flex; align-items:center; gap:7px; font-size:13px; color:var(--ink); background:var(--control2); padding:4px 6px 4px 12px; border-radius:20px; }
|
|
262
|
+
.step b { min-width:12px; text-align:center; font-size:14px; color:var(--ink); }
|
|
263
|
+
.step button { width:24px; height:24px; border-radius:50%; background:var(--raised); color:var(--muted); font-size:17px; line-height:22px; text-align:center; box-shadow:0 1px 2px var(--shadow); }
|
|
264
|
+
.step button:hover { color:var(--accent-ink); }
|
|
265
|
+
.total { font-size:12px; color:var(--muted); }
|
|
266
|
+
.total.warn { color:var(--warn); font-weight:600; }
|
|
267
|
+
.bar { display:flex; gap:10px 14px; align-items:center; flex-wrap:wrap; }
|
|
268
|
+
#copy { margin-left:auto; padding:9px 22px; background:var(--accent); color:#fff; font-size:14px; font-weight:700; }
|
|
269
|
+
#copy:hover { filter:brightness(1.07); }
|
|
270
|
+
.chips { flex:1 1 320px; min-width:0; display:flex; gap:7px; flex-wrap:wrap; align-items:center; }
|
|
271
|
+
.chip { font-size:12px; padding:4px 11px; border-radius:16px; border:0; color:#fff; background:var(--cc); font-weight:600; cursor:pointer; }
|
|
272
|
+
.chip:hover { filter:brightness(1.08); }
|
|
273
|
+
.banner { max-height:0; overflow:hidden; transition:max-height .25s ease, padding .22s ease, margin .22s ease; background:linear-gradient(90deg,var(--accent),#8275f2); color:#fff; border-radius:10px; font-weight:700; text-align:center; padding:0 14px; }
|
|
274
|
+
.banner.show { max-height:64px; padding:13px 14px; margin-top:10px; }
|
|
275
|
+
.banner.fail { background:linear-gradient(90deg,var(--warn),#e0894a); }
|
|
276
|
+
main { padding:18px 24px 60px; max-width:1120px; margin:0 auto; }
|
|
277
|
+
section { margin:0 0 26px; }
|
|
278
|
+
section > h2 { font-size:13px; text-transform:uppercase; letter-spacing:.08em; color:var(--c); margin:0 0 10px; border-bottom:1px solid color-mix(in srgb, var(--c) 24%, var(--line)); padding-bottom:6px; }
|
|
279
|
+
section > h2 .cnt { color:color-mix(in srgb, var(--c) 45%, var(--cnt)); margin-left:6px; }
|
|
280
|
+
.grid { display:grid; grid-template-columns:repeat(auto-fill,minmax(360px,1fr)); gap:10px; }
|
|
281
|
+
label.tech { display:flex; gap:12px; align-items:flex-start; background:color-mix(in srgb, var(--c) 5%, var(--surface)); border:1px solid color-mix(in srgb, var(--c) 18%, var(--line)); border-radius:10px; padding:11px 13px; cursor:pointer; transition:border-color .12s, box-shadow .12s, background .12s; }
|
|
282
|
+
label.tech:hover { border-color:color-mix(in srgb, var(--c) 45%, var(--surface)); }
|
|
283
|
+
label.tech input { margin-top:2px; width:17px; height:17px; accent-color:var(--c); flex:none; }
|
|
284
|
+
label.tech:has(input:checked) { border-color:var(--c); background:color-mix(in srgb, var(--c) 12%, var(--surface)); box-shadow:0 0 0 2px color-mix(in srgb, var(--c) 30%, transparent); }
|
|
285
|
+
.tech .ic2 { display:flex; gap:5px; flex:none; }
|
|
286
|
+
.tech .ico { width:40px; height:40px; flex:none; color:var(--c); }
|
|
287
|
+
.tech .n { font-weight:600; display:block; }
|
|
288
|
+
.tech .d { color:var(--muted); font-size:13.5px; display:block; margin-top:2px; }
|
|
289
|
+
.tech .gf { color:var(--accent-ink); font-size:11px; display:block; margin-top:5px; opacity:.85; }
|
|
290
|
+
.grouphdr { margin:30px 0 12px; font-size:12px; text-transform:uppercase; letter-spacing:.14em; font-weight:700; color:var(--c); opacity:.92; border-bottom:1px solid color-mix(in srgb, var(--c) 22%, var(--line)); padding-bottom:7px; }
|
|
291
|
+
main > .grouphdr:first-child { margin-top:2px; }
|
|
292
|
+
:root[data-theme="dark"] .grouphdr { color:color-mix(in srgb, var(--c) 62%, #fff); }
|
|
293
|
+
.goals { display:flex; gap:7px; flex-wrap:wrap; }
|
|
294
|
+
.goal { font-size:12px; padding:5px 12px; border-radius:16px; background:var(--control); color:var(--muted); font-weight:600; }
|
|
295
|
+
.goal:hover { color:var(--ink); }
|
|
296
|
+
.goal.on { background:var(--accent); color:#fff; }
|
|
297
|
+
label.tech.invent { border-style:dashed; background:transparent; }
|
|
298
|
+
label.tech.invent:hover { border-color:var(--c); }
|
|
299
|
+
label.tech.invent .n { color:var(--c); }
|
|
300
|
+
label.tech.hidden { display:none; }
|
|
301
|
+
footer { text-align:center; color:var(--foot); font-size:12px; padding:24px; }
|
|
302
|
+
</style>
|
|
303
|
+
</head>
|
|
304
|
+
<body>
|
|
305
|
+
<header>
|
|
306
|
+
<div class="hwrap">
|
|
307
|
+
<div class="titlerow">
|
|
308
|
+
<h1>BMad Method Brainstorming Selection</h1>
|
|
309
|
+
<button id="theme" class="themebtn" type="button" aria-label="Toggle dark mode" title="Toggle dark mode"></button>
|
|
310
|
+
</div>
|
|
311
|
+
<p class="sub">Compose your session, hit <strong>Copy prompt</strong>, and paste it back into the chat to begin. {{TOTAL}}</p>
|
|
312
|
+
|
|
313
|
+
<div class="composer">
|
|
314
|
+
<div class="grp">
|
|
315
|
+
<span class="glabel">Facilitation</span>
|
|
316
|
+
<div class="modes" id="modes">
|
|
317
|
+
<button type="button" class="mode on" data-mode="Facilitator">Facilitator</button>
|
|
318
|
+
<button type="button" class="mode" data-mode="Creative Partner">Creative Partner</button>
|
|
319
|
+
<button type="button" class="mode" data-mode="Ideate for me">Ideate for me</button>
|
|
320
|
+
</div>
|
|
321
|
+
<span class="modehint" id="modehint"></span>
|
|
322
|
+
</div>
|
|
323
|
+
<div class="grp">
|
|
324
|
+
<span class="glabel">Techniques</span>
|
|
325
|
+
<span class="pill">Picked <b id="pickN">0</b></span>
|
|
326
|
+
<span class="step">Random <button type="button" data-step="rand" data-d="-1">−</button><b id="randN">0</b><button type="button" data-step="rand" data-d="1">+</button></span>
|
|
327
|
+
<span class="step">Invent <button type="button" data-step="inv" data-d="-1">−</button><b id="invN">0</b><button type="button" data-step="inv" data-d="1">+</button></span>
|
|
328
|
+
<span class="step">AI picks <button type="button" data-step="ai" data-d="-1">−</button><b id="aiN">0</b><button type="button" data-step="ai" data-d="1">+</button></span>
|
|
329
|
+
<span class="total" id="total">Total 0 · 3–4 is the sweet spot</span>
|
|
330
|
+
<button id="copy" type="button">Copy prompt</button>
|
|
331
|
+
</div>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
{{GOALBAR}}
|
|
335
|
+
<div class="bar">
|
|
336
|
+
<span class="glabel">Jump to</span>
|
|
337
|
+
<div class="chips" id="chips">{{CHIPS}}</div>
|
|
338
|
+
</div>
|
|
339
|
+
|
|
340
|
+
<div class="banner" id="banner">✓ Copied! Now paste it into the chat to start your session.</div>
|
|
341
|
+
</div>
|
|
342
|
+
</header>
|
|
343
|
+
<main>
|
|
344
|
+
{{BODY}}
|
|
345
|
+
</main>
|
|
346
|
+
<footer>BMad Method · Brainstorming</footer>
|
|
347
|
+
<script>
|
|
348
|
+
(function(){
|
|
349
|
+
var $ = function(id){ return document.getElementById(id); };
|
|
350
|
+
var all = Array.prototype.slice;
|
|
351
|
+
var boxes = all.call(document.querySelectorAll('input[type=checkbox]'));
|
|
352
|
+
var techBoxes = boxes.filter(function(b){ return b.dataset.name; }); // real technique cards
|
|
353
|
+
var inventBoxes = boxes.filter(function(b){ return b.dataset.invent; }); // per-category "invent in the spirit of" cards
|
|
354
|
+
var header = document.querySelector('header');
|
|
355
|
+
var sections = all.call(document.querySelectorAll('section'));
|
|
356
|
+
var state = { mode: 'Facilitator', rand: 0, inv: 0, ai: 0 };
|
|
357
|
+
var MODE_HINTS = {
|
|
358
|
+
'Facilitator': 'A forcing function for your ideas — I prompt and push, but never supply them.',
|
|
359
|
+
'Creative Partner': 'We riff together — I facilitate and add ideas too, each logged as yours or mine.',
|
|
360
|
+
'Ideate for me': 'I run the whole session myself, then show you the result and offer to keep going.'
|
|
361
|
+
};
|
|
362
|
+
function setHint(){ $('modehint').textContent = MODE_HINTS[state.mode] || ''; }
|
|
363
|
+
|
|
364
|
+
var themeBtn = $('theme');
|
|
365
|
+
function setThemeIcon(){ themeBtn.textContent = document.documentElement.getAttribute('data-theme') === 'dark' ? '☀' : '☾'; }
|
|
366
|
+
themeBtn.addEventListener('click', function(){
|
|
367
|
+
var next = document.documentElement.getAttribute('data-theme') === 'dark' ? 'light' : 'dark';
|
|
368
|
+
document.documentElement.setAttribute('data-theme', next);
|
|
369
|
+
try { localStorage.setItem('bmad-theme', next); } catch(e){}
|
|
370
|
+
setThemeIcon();
|
|
371
|
+
});
|
|
372
|
+
|
|
373
|
+
all.call(document.querySelectorAll('.mode')).forEach(function(b){
|
|
374
|
+
b.addEventListener('click', function(){
|
|
375
|
+
all.call(document.querySelectorAll('.mode')).forEach(function(m){ m.classList.remove('on'); });
|
|
376
|
+
b.classList.add('on');
|
|
377
|
+
state.mode = b.dataset.mode;
|
|
378
|
+
setHint();
|
|
379
|
+
});
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
all.call(document.querySelectorAll('[data-step]')).forEach(function(btn){
|
|
383
|
+
btn.addEventListener('click', function(){
|
|
384
|
+
var k = btn.dataset.step, d = parseInt(btn.dataset.d, 10);
|
|
385
|
+
state[k] = Math.max(0, state[k] + d);
|
|
386
|
+
update();
|
|
387
|
+
});
|
|
388
|
+
});
|
|
389
|
+
|
|
390
|
+
// Category chips are jump-nav: click one to smooth-scroll its section into view,
|
|
391
|
+
// offsetting by the sticky header's height so the heading isn't hidden beneath it.
|
|
392
|
+
all.call(document.querySelectorAll('.chip')).forEach(function(chip){
|
|
393
|
+
chip.addEventListener('click', function(){
|
|
394
|
+
var sec = null;
|
|
395
|
+
for (var i = 0; i < sections.length; i++){ if (sections[i].dataset.cat === chip.dataset.cat){ sec = sections[i]; break; } }
|
|
396
|
+
if (!sec){ return; }
|
|
397
|
+
var top = sec.getBoundingClientRect().top + window.pageYOffset - header.offsetHeight - 8;
|
|
398
|
+
window.scrollTo({ top: top, behavior: 'smooth' });
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
boxes.forEach(function(b){ b.addEventListener('change', update); });
|
|
403
|
+
|
|
404
|
+
// A `classic` technique appears twice (lead "Proven & Professional" group + its home
|
|
405
|
+
// category), so de-dupe checked picks by name; the lead copy carries data-lead.
|
|
406
|
+
function checkedTech(){
|
|
407
|
+
var seen = {}, out = [];
|
|
408
|
+
techBoxes.forEach(function(b){
|
|
409
|
+
if (!b.checked || seen[b.dataset.name]) { return; }
|
|
410
|
+
seen[b.dataset.name] = 1;
|
|
411
|
+
out.push(b);
|
|
412
|
+
});
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
function checkedInvent(){ return inventBoxes.filter(function(b){ return b.checked; }); }
|
|
416
|
+
|
|
417
|
+
function update(){
|
|
418
|
+
$('pickN').textContent = checkedTech().length;
|
|
419
|
+
$('randN').textContent = state.rand;
|
|
420
|
+
$('invN').textContent = state.inv;
|
|
421
|
+
$('aiN').textContent = state.ai;
|
|
422
|
+
var total = checkedTech().length + state.rand + state.inv + checkedInvent().length + state.ai;
|
|
423
|
+
var t = $('total');
|
|
424
|
+
t.textContent = 'Total ' + total + ' · 3–4 is the sweet spot';
|
|
425
|
+
t.classList.toggle('warn', total > 5);
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
// "Great for" goal filter: clicking a goal narrows visible cards to those tagged with it.
|
|
429
|
+
var goalBtns = all.call(document.querySelectorAll('.goal'));
|
|
430
|
+
function activeGoals(){ return goalBtns.filter(function(b){ return b.classList.contains('on'); }).map(function(b){ return b.dataset.goal; }); }
|
|
431
|
+
function applyFilter(){
|
|
432
|
+
var act = activeGoals();
|
|
433
|
+
all.call(document.querySelectorAll('label.tech')).forEach(function(lab){
|
|
434
|
+
var inp = lab.querySelector('input');
|
|
435
|
+
if (inp.dataset.invent){ return; } // invent cards aren't goal-tagged — always visible
|
|
436
|
+
var good = (inp.dataset.good || '').split('|');
|
|
437
|
+
var show = !act.length || act.some(function(g){ return good.indexOf(g) >= 0; });
|
|
438
|
+
lab.classList.toggle('hidden', !show);
|
|
439
|
+
});
|
|
440
|
+
}
|
|
441
|
+
goalBtns.forEach(function(b){ b.addEventListener('click', function(){ b.classList.toggle('on'); applyFilter(); }); });
|
|
442
|
+
|
|
443
|
+
function randomPool(){
|
|
444
|
+
var picked = {};
|
|
445
|
+
checkedTech().forEach(function(b){ picked[b.dataset.name] = 1; });
|
|
446
|
+
// draw from unchecked, non-lead copies, skipping anything already picked
|
|
447
|
+
return techBoxes.filter(function(b){ return !b.checked && !b.dataset.lead && !picked[b.dataset.name]; });
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function sample(arr, n){
|
|
451
|
+
var a = arr.slice(), out = [];
|
|
452
|
+
while (out.length < n && a.length){ out.push(a.splice(Math.floor(Math.random() * a.length), 1)[0]); }
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
function compose(){
|
|
457
|
+
var picks = checkedTech().map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: false }; });
|
|
458
|
+
var rnd = sample(randomPool(), state.rand).map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: true }; });
|
|
459
|
+
var techs = picks.concat(rnd);
|
|
460
|
+
var L = ["Let's run my brainstorming session.", "", 'Facilitation mode: ' + state.mode + '.'];
|
|
461
|
+
if (techs.length){
|
|
462
|
+
L.push("", 'Techniques to use:');
|
|
463
|
+
techs.forEach(function(t, i){
|
|
464
|
+
L.push((i + 1) + '.' + (t.r ? ' (random pick)' : '') + ' ' + t.n + ' · ' + t.c);
|
|
465
|
+
L.push(' ' + t.d);
|
|
466
|
+
});
|
|
467
|
+
}
|
|
468
|
+
var extra = [];
|
|
469
|
+
if (state.inv > 0){ extra.push('invent ' + state.inv + ' brand-new technique' + (state.inv > 1 ? 's' : '') + ' on the fly'); }
|
|
470
|
+
checkedInvent().forEach(function(b){ extra.push('invent 1 new technique in the spirit of ' + b.dataset.invent); });
|
|
471
|
+
if (state.ai > 0){ extra.push('you choose ' + state.ai + ' more technique' + (state.ai > 1 ? 's' : '') + ' that fit my goal'); }
|
|
472
|
+
if (extra.length){ L.push("", 'Then: ' + extra.join('; and ') + '.'); }
|
|
473
|
+
if (!techs.length && !extra.length){
|
|
474
|
+
L.push("", state.mode === 'Ideate for me'
|
|
475
|
+
? 'Run the whole session yourself — pick the techniques, generate the ideas, then show me the result.'
|
|
476
|
+
: 'Help me choose 3–4 techniques to start.');
|
|
477
|
+
}
|
|
478
|
+
return L.join('\n');
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
function fallbackCopy(t){
|
|
482
|
+
var ta = document.createElement('textarea');
|
|
483
|
+
ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';
|
|
484
|
+
document.body.appendChild(ta); ta.focus(); ta.select();
|
|
485
|
+
var ok = false;
|
|
486
|
+
try { ok = document.execCommand('copy'); } catch(e){ ok = false; }
|
|
487
|
+
document.body.removeChild(ta);
|
|
488
|
+
return ok;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
function flash(ok, text){
|
|
492
|
+
var b = $('banner');
|
|
493
|
+
b.classList.toggle('fail', !ok);
|
|
494
|
+
b.innerHTML = ok
|
|
495
|
+
? '✓ Copied! Now paste it into the chat to start your session.'
|
|
496
|
+
: '⚠ Couldn’t reach the clipboard — copy the text in the box, then paste it into the chat.';
|
|
497
|
+
b.classList.add('show');
|
|
498
|
+
setTimeout(function(){ b.classList.remove('show'); }, 4500);
|
|
499
|
+
// Last resort on a hard failure: a prefilled, selectable prompt so the text is never lost.
|
|
500
|
+
if (!ok){ window.prompt('Copy this, then paste it into the chat:', text); }
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
$('copy').addEventListener('click', function(){
|
|
504
|
+
var text = compose();
|
|
505
|
+
if (navigator.clipboard && navigator.clipboard.writeText){
|
|
506
|
+
navigator.clipboard.writeText(text).then(
|
|
507
|
+
function(){ flash(true, text); },
|
|
508
|
+
function(){ flash(fallbackCopy(text), text); }
|
|
509
|
+
);
|
|
510
|
+
} else { flash(fallbackCopy(text), text); }
|
|
511
|
+
});
|
|
512
|
+
|
|
513
|
+
setHint();
|
|
514
|
+
setThemeIcon();
|
|
515
|
+
update();
|
|
516
|
+
})();
|
|
517
|
+
</script>
|
|
518
|
+
</body>
|
|
519
|
+
</html>
|
|
520
|
+
"""
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
# --- browse-page layout: a "Proven & Professional" lead group, then super-groups ----------
|
|
524
|
+
CLASSIC_GROUP = "Proven & Professional"
|
|
525
|
+
LEAD_HUE = "#3d4f73" # a dignified slate for the professional lead group
|
|
526
|
+
|
|
527
|
+
# Super-group order for the shipped categories. Categories not listed (e.g. user-added
|
|
528
|
+
# via --extra) render last under "More", alphabetically — so custom catalogs always show.
|
|
529
|
+
CATEGORY_GROUPS = (
|
|
530
|
+
("Structured & Analytical", ("structured", "deep")),
|
|
531
|
+
("Creative & Generative", ("creative", "biomimetic", "cultural", "speculative_future", "quantum")),
|
|
532
|
+
("Wild & Playful", ("wild", "absurdist", "theatrical", "constraint")),
|
|
533
|
+
("Introspective & Personal", ("introspective_delight", "collaborative")),
|
|
534
|
+
)
|
|
535
|
+
|
|
536
|
+
# Human labels for the `good_for` goal tags; this dict's order is the filter-bar order.
|
|
537
|
+
GOAL_LABELS = {
|
|
538
|
+
"feature": "Build a feature",
|
|
539
|
+
"novel": "Novel concept",
|
|
540
|
+
"strategy": "Strategy",
|
|
541
|
+
"planning": "Planning",
|
|
542
|
+
"diagnosis": "Diagnose",
|
|
543
|
+
"personal": "Personal / life",
|
|
544
|
+
"unstuck": "Get unstuck",
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _good_for_label(good: str) -> str:
|
|
549
|
+
parts = [GOAL_LABELS.get(g, g) for g in good.split("|") if g]
|
|
550
|
+
return ("Great for: " + " · ".join(parts)) if parts else ""
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _svg(inner: str) -> str:
|
|
554
|
+
return f'<svg class="ico" viewBox="0 0 44 44" xmlns="http://www.w3.org/2000/svg">{CHIP}{inner}</svg>'
|
|
555
|
+
|
|
556
|
+
|
|
557
|
+
def _card(r: dict, lead: bool = False) -> str:
|
|
558
|
+
"""One technique card. `lead=True` cards live in the cross-cutting professional group;
|
|
559
|
+
they carry their own category hue (inline --c) and data-lead so selection can de-dupe."""
|
|
560
|
+
name = html.escape(r["technique_name"])
|
|
561
|
+
desc = html.escape(r["description"])
|
|
562
|
+
hue, glyph = category_style(r["category"])
|
|
563
|
+
disp_cat = html.escape(pretty(r["category"]))
|
|
564
|
+
good = html.escape(r.get("good_for", ""))
|
|
565
|
+
prov = html.escape(r.get("provenance", ""))
|
|
566
|
+
style = f' style="--c:{hue}"' if lead else ""
|
|
567
|
+
lead_attr = ' data-lead="1"' if lead else ""
|
|
568
|
+
gf = _good_for_label(r.get("good_for", ""))
|
|
569
|
+
gf_html = f'<span class="gf">{html.escape(gf)}</span>' if gf else ""
|
|
570
|
+
return (
|
|
571
|
+
f'<label class="tech"{style}><input type="checkbox" '
|
|
572
|
+
f'data-name="{name}" data-cat="{disp_cat}" data-desc="{desc}" data-good="{good}" data-prov="{prov}"{lead_attr}>'
|
|
573
|
+
f'<span class="ic2">{_svg(glyph)}{_svg(tech_icon(r["technique_name"]))}</span>'
|
|
574
|
+
f'<span><span class="n">{name}</span><span class="d">{desc}</span>{gf_html}</span></label>'
|
|
575
|
+
)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _invent_card(disp_cat: str, glyph: str) -> str:
|
|
579
|
+
"""A dashed 'invent on the fly, in this category's spirit' card appended to each section."""
|
|
580
|
+
return (
|
|
581
|
+
f'<label class="tech invent"><input type="checkbox" data-invent="{disp_cat}">'
|
|
582
|
+
f'<span class="ic2">{_svg(glyph)}</span>'
|
|
583
|
+
f'<span><span class="n">✨ Invent a {disp_cat} technique</span>'
|
|
584
|
+
f'<span class="d">Make up a brand-new technique on the fly, in the spirit of {disp_cat}</span></span></label>'
|
|
585
|
+
)
|
|
586
|
+
|
|
587
|
+
|
|
588
|
+
def html_doc(rows: list[dict]) -> str:
|
|
589
|
+
"""Render the self-contained 'browse all techniques' selection page from the catalog.
|
|
590
|
+
|
|
591
|
+
Deterministic ordering so the shipped asset can be snapshot-tested against the CSV:
|
|
592
|
+
a cross-cutting "Proven & Professional" lead group (every `classic`-tagged row), then
|
|
593
|
+
the categories in fixed super-group order, then any unlisted/custom categories under
|
|
594
|
+
"More" alphabetically. Techniques render in file order within a category. A `classic`
|
|
595
|
+
row appears both in the lead group and its home category; the page de-dupes on select.
|
|
596
|
+
"""
|
|
597
|
+
groups: dict[str, list[dict]] = {}
|
|
598
|
+
for r in rows:
|
|
599
|
+
groups.setdefault(r["category"], []).append(r)
|
|
600
|
+
|
|
601
|
+
body: list[str] = []
|
|
602
|
+
chips: list[str] = []
|
|
603
|
+
|
|
604
|
+
def add_section(cat: str) -> None:
|
|
605
|
+
hue, glyph = category_style(cat)
|
|
606
|
+
disp = html.escape(pretty(cat))
|
|
607
|
+
cards = [_card(r) for r in groups[cat]]
|
|
608
|
+
cards.append(_invent_card(disp, glyph))
|
|
609
|
+
chips.append(f'<button type="button" class="chip" data-cat="{disp}" style="--cc:{hue}">{disp}</button>')
|
|
610
|
+
body.append(
|
|
611
|
+
f'<section data-cat="{disp}" style="--c:{hue}"><h2>{disp}<span class="cnt">{len(groups[cat])}</span></h2>'
|
|
612
|
+
f'<div class="grid">{"".join(cards)}</div></section>'
|
|
613
|
+
)
|
|
614
|
+
|
|
615
|
+
# 1) lead group — every classic-tagged technique, cross-category (no invent card here)
|
|
616
|
+
classics = [r for r in rows if r.get("provenance", "").lower() == "classic"]
|
|
617
|
+
if classics:
|
|
618
|
+
disp = html.escape(CLASSIC_GROUP)
|
|
619
|
+
lead_cards = "".join(_card(r, lead=True) for r in classics)
|
|
620
|
+
chips.append(f'<button type="button" class="chip" data-cat="{disp}" style="--cc:{LEAD_HUE}">{disp}</button>')
|
|
621
|
+
body.append(
|
|
622
|
+
f'<section data-cat="{disp}" style="--c:{LEAD_HUE}"><h2>{disp}<span class="cnt">{len(classics)}</span></h2>'
|
|
623
|
+
f'<div class="grid">{lead_cards}</div></section>'
|
|
624
|
+
)
|
|
625
|
+
|
|
626
|
+
# 2) shipped categories, in super-group order
|
|
627
|
+
placed = set()
|
|
628
|
+
for group_title, cats in CATEGORY_GROUPS:
|
|
629
|
+
present = [c for c in cats if c in groups]
|
|
630
|
+
if not present:
|
|
631
|
+
continue
|
|
632
|
+
hue, _ = category_style(present[0])
|
|
633
|
+
body.append(f'<h2 class="grouphdr" style="--c:{hue}">{html.escape(group_title)}</h2>')
|
|
634
|
+
for c in present:
|
|
635
|
+
add_section(c)
|
|
636
|
+
placed.add(c)
|
|
637
|
+
|
|
638
|
+
# 3) leftover (custom / --extra) categories, alphabetically
|
|
639
|
+
leftover = sorted(c for c in groups if c not in placed)
|
|
640
|
+
if leftover:
|
|
641
|
+
body.append('<h2 class="grouphdr" style="--c:#8a8f9e">More</h2>')
|
|
642
|
+
for c in leftover:
|
|
643
|
+
add_section(c)
|
|
644
|
+
|
|
645
|
+
# goal-affinity filter bar — only if the catalog actually carries good_for tags
|
|
646
|
+
present_goals: set[str] = set()
|
|
647
|
+
for r in rows:
|
|
648
|
+
for g in (r.get("good_for", "") or "").split("|"):
|
|
649
|
+
if g:
|
|
650
|
+
present_goals.add(g)
|
|
651
|
+
goalbar = ""
|
|
652
|
+
if present_goals:
|
|
653
|
+
ordered = [g for g in GOAL_LABELS if g in present_goals] + sorted(present_goals - set(GOAL_LABELS))
|
|
654
|
+
gchips = "".join(
|
|
655
|
+
f'<button type="button" class="goal" data-goal="{html.escape(g)}">{html.escape(GOAL_LABELS.get(g, g))}</button>'
|
|
656
|
+
for g in ordered
|
|
657
|
+
)
|
|
658
|
+
goalbar = f'<div class="bar"><span class="glabel">Great for</span><div class="goals" id="goals">{gchips}</div></div>'
|
|
659
|
+
|
|
660
|
+
total = html.escape(f"{len(rows)} techniques across {len(groups)} categories.")
|
|
661
|
+
return (
|
|
662
|
+
SELECTOR_TEMPLATE.replace("{{BODY}}", "\n".join(body))
|
|
663
|
+
.replace("{{CHIPS}}", "".join(chips))
|
|
664
|
+
.replace("{{GOALBAR}}", goalbar)
|
|
665
|
+
.replace("{{TOTAL}}", total)
|
|
666
|
+
)
|
|
667
|
+
|
|
668
|
+
|
|
669
|
+
def main(argv: list[str] | None = None) -> int:
|
|
670
|
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
671
|
+
p.add_argument("--file", type=Path, default=DEFAULT_FILE, help="technique CSV (default: sibling assets/brain-methods.csv)")
|
|
672
|
+
p.add_argument("--extra", type=Path, help="JSON overlay of additional techniques (customize.toml additional_techniques), merged into every command")
|
|
673
|
+
p.add_argument("--json", action="store_true", help="emit structured JSON instead of lean text")
|
|
674
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
675
|
+
sub.add_parser("categories", help="list category names + counts")
|
|
676
|
+
pl = sub.add_parser("list", help="the index: category/name/gist (needs --category or --all)")
|
|
677
|
+
pl.add_argument("--category", action="append", help="filter to a category (repeatable)")
|
|
678
|
+
pl.add_argument("--all", action="store_true", help="dump the entire catalog (deliberate; large)")
|
|
679
|
+
ps = sub.add_parser("show", help="full gist + detail file for named techniques")
|
|
680
|
+
ps.add_argument("names", nargs="+")
|
|
681
|
+
pr = sub.add_parser("random", help="pick techniques at random")
|
|
682
|
+
pr.add_argument("--category", action="append", help="restrict to a category (repeatable)")
|
|
683
|
+
pr.add_argument("-n", type=int, default=1, help="how many (default 1)")
|
|
684
|
+
ph = sub.add_parser("html", help="write the offline 'browse all' selection page")
|
|
685
|
+
ph.add_argument("--out", help="file to write the page to (required; never prints the catalog)")
|
|
686
|
+
args = p.parse_args(argv)
|
|
687
|
+
|
|
688
|
+
if not args.file.is_file():
|
|
689
|
+
print(f"error: technique file not found: {args.file}", file=sys.stderr)
|
|
690
|
+
return 2
|
|
691
|
+
rows = load(args.file)
|
|
692
|
+
if args.extra:
|
|
693
|
+
if not args.extra.is_file():
|
|
694
|
+
print(f"error: --extra file not found: {args.extra}", file=sys.stderr)
|
|
695
|
+
return 2
|
|
696
|
+
rows += load_extra(args.extra)
|
|
697
|
+
csv_dir = args.file.resolve().parent
|
|
698
|
+
|
|
699
|
+
if args.cmd == "categories":
|
|
700
|
+
print(fmt_categories(categories(rows), args.json))
|
|
701
|
+
elif args.cmd == "list":
|
|
702
|
+
if not args.category and not args.all:
|
|
703
|
+
print(
|
|
704
|
+
"error: `list` needs --category (one or more) — or --all to dump the whole "
|
|
705
|
+
"catalog on purpose. Use `categories` for the cheap map, or `random` to draw blind.",
|
|
706
|
+
file=sys.stderr,
|
|
707
|
+
)
|
|
708
|
+
return 2
|
|
709
|
+
print(fmt_list(filter_cats(rows, args.category), args.json))
|
|
710
|
+
elif args.cmd == "show":
|
|
711
|
+
found, missing = find(rows, args.names)
|
|
712
|
+
for m in missing:
|
|
713
|
+
print(f"# not found: {m}", file=sys.stderr)
|
|
714
|
+
if not found:
|
|
715
|
+
return 1
|
|
716
|
+
print(fmt_show(found, csv_dir, args.json))
|
|
717
|
+
elif args.cmd == "random":
|
|
718
|
+
pool = filter_cats(rows, args.category)
|
|
719
|
+
if not pool:
|
|
720
|
+
print("# no techniques match", file=sys.stderr)
|
|
721
|
+
return 1
|
|
722
|
+
n = max(0, min(args.n, len(pool))) # clamp: never crash on a negative or oversized -n
|
|
723
|
+
print(fmt_list(random.sample(pool, n), args.json))
|
|
724
|
+
elif args.cmd == "html":
|
|
725
|
+
if not args.out:
|
|
726
|
+
print(
|
|
727
|
+
"error: `html` needs --out PATH — it writes the selection page to a file and "
|
|
728
|
+
"never prints the catalog to stdout (which would defeat the point).",
|
|
729
|
+
file=sys.stderr,
|
|
730
|
+
)
|
|
731
|
+
return 2
|
|
732
|
+
out = Path(args.out)
|
|
733
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
734
|
+
out.write_text(html_doc(rows), encoding="utf-8")
|
|
735
|
+
print(f"wrote {out} ({len(rows)} techniques, {len(categories(rows))} categories)")
|
|
736
|
+
return 0
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
if __name__ == "__main__":
|
|
740
|
+
sys.exit(main())
|