bmad-brainstorming: fix code-review findings

memlog.py
- Parse frontmatter by the first line that is exactly `---`, so a `---` inside a
  topic/goal value no longer truncates the block, drops `status`, and breaks resume
  forever. Neutralize newlines in field values on render too.

brain.py (selector page + CLI)
- Composer: category toggles now define session scope; the text filter is a pure
  browse aid. checked() and the random pool both key off scope (offCats), so hidden
  cards are never silently copied and a stray filter term can't starve a random draw.
- Clipboard: only show the "Copied!" banner when the copy actually succeeds; on
  failure show a warning and a prefilled prompt() so the text is never lost.
- category_style: fall back to the neutral glyph instead of KeyError if the hue/glyph
  dicts ever desync.
- random: clamp -n so a negative/oversized value returns cleanly instead of crashing.
- --extra: merge a JSON overlay of additional_techniques into every command, so the
  browse page and category draws include custom techniques/categories as advertised.

docs
- SKILL.md: fix dangling `## Choosing Your Mode` anchor and the "Copy selection"
  button label; document --extra in the regen instructions.
- mode-autonomous.md: persist the mode flip when handing off from autonomous, so a
  resume restores the new stance.
- finalize.md: grammar/typo fixes (CodeRabbit).

tests
- Regression tests for the memlog `---` fix, --extra merge, negative -n, and the
  category fallback; regenerated the snapshot-tested selection page. Renamed the
  shadowing `type`/`l` locals flagged by CodeRabbit. 52 passing.
This commit is contained in:
Brian Madison 2026-05-31 23:40:57 -05:00
parent 93794aa0dd
commit e966110e71
8 changed files with 216 additions and 60 deletions

View File

@ -36,7 +36,7 @@ These hold the whole run, in every mode. They fight your defaults, so hold them
- **Keep shifting the creative domain** — roughly every 510 turns (or every ~10 ideas when you're the one generating), usually by moving to the next technique. Divergence is a discipline, not a mood. - **Keep shifting the creative domain** — roughly every 510 turns (or every ~10 ideas when you're the one generating), usually by moving to the next technique. Divergence is a discipline, not a mood.
- **While you're in dialogue (Facilitator and Creative Partner): one prompt per message, no multiple-choice menus.** Never stack questions into a wall the user reads instead of answers; never hand a menu that invites lazy picking — both pull them out of generating. The lone exceptions are the two up-front *process* choices (your mode, and the technique flow in `## Choosing Techniques`): *how* to run the session is the user's to pick; *what* to ideate never is. - **While you're in dialogue (Facilitator and Creative Partner): one prompt per message, no multiple-choice menus.** Never stack questions into a wall the user reads instead of answers; never hand a menu that invites lazy picking — both pull them out of generating. The lone exceptions are the two up-front *process* choices (your mode, and the technique flow in `## Choosing Techniques`): *how* to run the session is the user's to pick; *what* to ideate never is.
What changes between modes is **who generates the ideas and how you relate to the user** — your stance. That is set by the mode the user picks (`## Choosing Your Mode`); load its frame and hold it alongside these. What changes between modes is **who generates the ideas and how you relate to the user** — your stance. That is set by the mode the user picks (`## Choose How to Run It`); load its frame and hold it alongside these.
## Framing — The Memlog ## Framing — The Memlog
@ -65,7 +65,7 @@ Two things get set before ideating: the **facilitation mode** (your stance) and
**Primary — the composer page.** Send the user to it: **Primary — the composer page.** Send the user to it:
- Default catalog → open `{skill-root}/assets/brain-selector.html`. - Default catalog → open `{skill-root}/assets/brain-selector.html`.
- Customized catalog (overridden `{workflow.brain_methods}` or any `{workflow.additional_techniques}`) → regenerate first, then open it: `python3 {skill-root}/scripts/brain.py --file {workflow.brain_methods} html --out {doc_workspace}/brain-selector.html`. - Customized catalog (overridden `{workflow.brain_methods}` or any `{workflow.additional_techniques}`) → regenerate first, then open it. If there are `{workflow.additional_techniques}`, write them to a JSON file (a list of `{category, technique_name, description}` objects) and pass it as `--extra` so the page includes them too: `python3 {skill-root}/scripts/brain.py --file {workflow.brain_methods} [--extra {doc_workspace}/extra-techniques.json] html --out {doc_workspace}/brain-selector.html`.
There they choose a facilitation mode, build a technique batch (tick cards, **+Random**, **+Invent**, **AI picks**), filter by category if they want, click **Copy prompt**, and paste it back. Read that pasted block: There they choose a facilitation mode, build a technique batch (tick cards, **+Random**, **+Invent**, **AI picks**), filter by category if they want, click **Copy prompt**, and paste it back. Read that pasted block:
@ -116,7 +116,7 @@ The library is large, so **never pull it whole into context.** The only way in i
Once the user has chosen, run that flow and reach no further than the calls it names: Once the user has chosen, run that flow and reach no further than the calls it names:
- **Facilitator Chosen** — from the goal, your `{workflow.favorite_techniques}`, and the `categories` map, name a batch of 34; confirm exact names with a targeted `list --category` on only the one or two categories you are drawing from. Never enumerate the library to choose. - **Facilitator Chosen** — from the goal, your `{workflow.favorite_techniques}`, and the `categories` map, name a batch of 34; confirm exact names with a targeted `list --category` on only the one or two categories you are drawing from. Never enumerate the library to choose.
- **Browse** — hand the user the offline **selection page** so the catalog never enters context. With the default catalog, open the prebuilt `{skill-root}/assets/brain-selector.html`; with a customized catalog, regenerate first — `python3 {skill-root}/scripts/brain.py --file {workflow.brain_methods} html --out {doc_workspace}/brain-selector.html` — then open that. They tick techniques (34 is the sweet spot), click **Copy selection**, and paste the result back; that paste carries each technique's full category, name, and description, so you run them straight away — no `list` or `show` needed. - **Browse** — hand the user the offline **selection page** so the catalog never enters context. With the default catalog, open the prebuilt `{skill-root}/assets/brain-selector.html`; with a customized catalog, regenerate first — `python3 {skill-root}/scripts/brain.py --file {workflow.brain_methods} [--extra {doc_workspace}/extra-techniques.json] html --out {doc_workspace}/brain-selector.html` (pass `--extra` when there are `{workflow.additional_techniques}`) — then open that. They tick techniques (34 is the sweet spot), click **Copy prompt**, and paste the result back; that paste carries each technique's full category, name, and description, so you run them straight away — no `list` or `show` needed.
- **Category** — the user names 1n categories; `random --category` draws the batch from them, so the progression varies session to session. No listing needed. - **Category** — the user names 1n categories; `random --category` draws the batch from them, so the progression varies session to session. No listing needed.
- **Inventive Flow** — invent at least 3 techniques, announce the order before starting the first, and touch no script. Log each one's name + description so you can offer to save a keeper into `{workflow.additional_techniques}` (via `bmad-customize`) at wrap-up. - **Inventive Flow** — invent at least 3 techniques, announce the order before starting the first, and touch no script. Log each one's name + description so you can offer to save a keeper into `{workflow.additional_techniques}` (via `bmad-customize`) at wrap-up.

View File

@ -36,6 +36,7 @@
.chip:not(.on) { opacity:.9; } .chip:not(.on) { opacity:.9; }
.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; } .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; }
.banner.show { max-height:64px; padding:13px 14px; margin-top:10px; } .banner.show { max-height:64px; padding:13px 14px; margin-top:10px; }
.banner.fail { background:linear-gradient(90deg,var(--warn),#e0894a); }
main { padding:18px 24px 60px; max-width:1120px; margin:0 auto; } main { padding:18px 24px 60px; max-width:1120px; margin:0 auto; }
section { margin:0 0 26px; } section { margin:0 0 26px; }
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%, #e6e8f0); padding-bottom:6px; } 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%, #e6e8f0); padding-bottom:6px; }
@ -131,13 +132,19 @@
chip.classList.toggle('on', on); chip.classList.toggle('on', on);
if (on){ delete offCats[chip.dataset.cat]; } else { offCats[chip.dataset.cat] = true; } if (on){ delete offCats[chip.dataset.cat]; } else { offCats[chip.dataset.cat] = true; }
applyFilter(); applyFilter();
update(); // a toggled-off category leaves the session, so counts must refresh too
}); });
}); });
boxes.forEach(function(b){ b.addEventListener('change', update); }); boxes.forEach(function(b){ b.addEventListener('change', update); });
q.addEventListener('input', applyFilter); q.addEventListener('input', applyFilter);
function checked(){ return boxes.filter(function(b){ return b.checked; }); } // A category toggled off (offCats) leaves the session entirely; the text filter is a
// transient browse aid that never changes what's selected. So both manual picks and the
// random pool key off offCats — never the search box — keeping the copied prompt in step
// with what the user sees, and never starving a random draw because of a stray filter term.
function inScope(b){ return !offCats[b.dataset.cat]; }
function checked(){ return boxes.filter(function(b){ return b.checked && inScope(b); }); }
function update(){ function update(){
$('pickN').textContent = checked().length; $('pickN').textContent = checked().length;
@ -163,11 +170,7 @@
}); });
} }
function visibleUnchecked(){ function randomPool(){ return boxes.filter(function(b){ return !b.checked && inScope(b); }); }
return boxes.filter(function(b){
return !b.checked && b.closest('label.tech').style.display !== 'none';
});
}
function sample(arr, n){ function sample(arr, n){
var a = arr.slice(), out = []; var a = arr.slice(), out = [];
@ -177,7 +180,7 @@
function compose(){ function compose(){
var picks = checked().map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: false }; }); var picks = checked().map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: false }; });
var rnd = sample(visibleUnchecked(), state.rand).map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: true }; }); var rnd = sample(randomPool(), state.rand).map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: true }; });
var techs = picks.concat(rnd); var techs = picks.concat(rnd);
var L = ["Let's run my brainstorming session.", "", 'Facilitation mode: ' + state.mode + '.']; var L = ["Let's run my brainstorming session.", "", 'Facilitation mode: ' + state.mode + '.'];
if (techs.length){ if (techs.length){
@ -203,16 +206,32 @@
var ta = document.createElement('textarea'); var ta = document.createElement('textarea');
ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.focus(); ta.select(); document.body.appendChild(ta); ta.focus(); ta.select();
try { document.execCommand('copy'); } catch(e){} var ok = false;
try { ok = document.execCommand('copy'); } catch(e){ ok = false; }
document.body.removeChild(ta); document.body.removeChild(ta);
return ok;
}
function flash(ok, text){
var b = $('banner');
b.classList.toggle('fail', !ok);
b.innerHTML = ok
? '✓ Copied! Now paste it into the chat to start your session.'
: '⚠ Couldnt reach the clipboard — copy the text in the box, then paste it into the chat.';
b.classList.add('show');
setTimeout(function(){ b.classList.remove('show'); }, 4500);
// Last resort on a hard failure: a prefilled, selectable prompt so the text is never lost.
if (!ok){ window.prompt('Copy this, then paste it into the chat:', text); }
} }
$('copy').addEventListener('click', function(){ $('copy').addEventListener('click', function(){
var text = compose(); var text = compose();
var show = function(){ var b = $('banner'); b.classList.add('show'); setTimeout(function(){ b.classList.remove('show'); }, 4500); };
if (navigator.clipboard && navigator.clipboard.writeText){ if (navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(show, function(){ fallbackCopy(text); show(); }); navigator.clipboard.writeText(text).then(
} else { fallbackCopy(text); show(); } function(){ flash(true, text); },
function(){ flash(fallbackCopy(text), text); }
);
} else { flash(fallbackCopy(text), text); }
}); });
update(); update();

View File

@ -23,4 +23,4 @@ Each artifact is a fresh, token-expensive generation, so the user opts in. Ask w
If the session used invented techniques, offer to save a keeper into `{workflow.additional_techniques}` via `bmad-customize` user preferences. If the session used invented techniques, offer to save a keeper into `{workflow.additional_techniques}` via `bmad-customize` user preferences.
After producing what they chose, offer them ideas for deep dive brainstorming new sessions, offer to full extrapolate any ideas into an html report (autonomously brainstorm on their behalf), and most importantly: execute each `{workflow.external_handoffs}` instruction. Then share the artifact paths (and any handoff destinations), invoke `bmad-help` to suggest where this leads next in the BMad ecosystem, let them know if they feel a produced intent is detailed enough the could jump right into passing it to bmad-spec or any other analysis tool (outlined from bmad hlep) and run `{workflow.on_complete}` if non-empty. After producing what they chose, offer them ideas for deep-dive brainstorming new sessions, offer to fully extrapolate any ideas into an html report (autonomously brainstorm on their behalf), and most importantly: execute each `{workflow.external_handoffs}` instruction. Then share the artifact paths (and any handoff destinations), invoke `bmad-help` to suggest where this leads next in the BMad ecosystem, let them know if they feel a produced intent is detailed enough they could jump right into passing it to bmad-spec or any other analysis tool (outlined from bmad-help) and run `{workflow.on_complete}` if non-empty.

View File

@ -5,6 +5,6 @@ The user handed you the topic and wants to see what you come up with on your own
- **Run a real divergent session yourself.** Pick and run techniques on your own (use `brain.py` as in `## Choosing Techniques`, but *you* choose — no menu for the user), capturing each idea to the memlog with `--type idea --by coach`, marking each technique switch with a `technique` entry, shifting the creative domain every ~10 ideas, aiming past 100. Push past the obvious. - **Run a real divergent session yourself.** Pick and run techniques on your own (use `brain.py` as in `## Choosing Techniques`, but *you* choose — no menu for the user), capturing each idea to the memlog with `--type idea --by coach`, marking each technique switch with a `technique` entry, shifting the creative domain every ~10 ideas, aiming past 100. Push past the obvious.
- **Don't pepper the user with questions** — this is your run. One quick confirm of topic and goal up front is plenty. - **Don't pepper the user with questions** — this is your run. One quick confirm of topic and goal up front is plenty.
- **When it's mined out, synthesize and produce the artifact.** Go to `## Wrap-Up` (`references/finalize.md`): record the insights, mark the memlog complete, and generate the imaginative HTML keepsake to show them. - **When it's mined out, synthesize and produce the artifact.** Go to `## Wrap-Up` (`references/finalize.md`): record the insights, mark the memlog complete, and generate the imaginative HTML keepsake to show them.
- **Then, because a human is here, offer to keep going together.** They may want to push an idea further or react to what you found — if so, switch into **Facilitator** or **Creative Partner** (load that frame) and continue from the same memlog. - **Then, because a human is here, offer to keep going together.** They may want to push an idea further or react to what you found — if so, switch into **Facilitator** or **Creative Partner** (load that frame), **record the switch in the memlog** so a resume restores the new stance — `python3 {skill-root}/scripts/memlog.py set --workspace {doc_workspace} --key mode --value <facilitator|partner>` and continue from the same memlog.
This is the interactive sibling of headless mode (`references/headless.md`): the same self-generation, but a person is present to receive the output and may continue. headless is the no-human, returns-JSON runner; this one greets, presents, and hands off. This is the interactive sibling of headless mode (`references/headless.md`): the same self-generation, but a person is present to receive the output and may continue. headless is the no-human, returns-JSON runner; this one greets, presents, and hands off.

View File

@ -22,6 +22,10 @@ Commands:
rather than stdout: dumping the full catalog into context is a footgun, so reaching the 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. 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. Default output is lean text for an LLM to read; pass --json for structured output.
""" """
import argparse import argparse
@ -46,6 +50,24 @@ def load(file: Path) -> list[dict]:
return rows 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(),
})
return rows
def categories(rows: list[dict]) -> list[tuple[str, int]]: def categories(rows: list[dict]) -> list[tuple[str, int]]:
counts: dict[str, int] = {} counts: dict[str, int] = {}
for r in rows: for r in rows:
@ -269,7 +291,7 @@ def _hsl_hex(deg: int, s: float, lt: float) -> str:
def category_style(cat: str) -> tuple[str, str]: def category_style(cat: str) -> tuple[str, str]:
"""(hue, glyph markup) for a category — crafted for the shipped set, derived for extras.""" """(hue, glyph markup) for a category — crafted for the shipped set, derived for extras."""
if cat in _HUES: if cat in _HUES:
return _HUES[cat], _GLYPHS[cat] return _HUES[cat], _GLYPHS.get(cat, _FALLBACK_GLYPH)
deg = int(hashlib.md5(cat.encode("utf-8")).hexdigest(), 16) % 360 deg = int(hashlib.md5(cat.encode("utf-8")).hexdigest(), 16) % 360
return _hsl_hex(deg, 0.58, 0.52), _FALLBACK_GLYPH return _hsl_hex(deg, 0.58, 0.52), _FALLBACK_GLYPH
@ -443,6 +465,7 @@ SELECTOR_TEMPLATE = r"""<!DOCTYPE html>
.chip:not(.on) { opacity:.9; } .chip:not(.on) { opacity:.9; }
.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; } .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; }
.banner.show { max-height:64px; padding:13px 14px; margin-top:10px; } .banner.show { max-height:64px; padding:13px 14px; margin-top:10px; }
.banner.fail { background:linear-gradient(90deg,var(--warn),#e0894a); }
main { padding:18px 24px 60px; max-width:1120px; margin:0 auto; } main { padding:18px 24px 60px; max-width:1120px; margin:0 auto; }
section { margin:0 0 26px; } section { margin:0 0 26px; }
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%, #e6e8f0); padding-bottom:6px; } 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%, #e6e8f0); padding-bottom:6px; }
@ -526,13 +549,19 @@ SELECTOR_TEMPLATE = r"""<!DOCTYPE html>
chip.classList.toggle('on', on); chip.classList.toggle('on', on);
if (on){ delete offCats[chip.dataset.cat]; } else { offCats[chip.dataset.cat] = true; } if (on){ delete offCats[chip.dataset.cat]; } else { offCats[chip.dataset.cat] = true; }
applyFilter(); applyFilter();
update(); // a toggled-off category leaves the session, so counts must refresh too
}); });
}); });
boxes.forEach(function(b){ b.addEventListener('change', update); }); boxes.forEach(function(b){ b.addEventListener('change', update); });
q.addEventListener('input', applyFilter); q.addEventListener('input', applyFilter);
function checked(){ return boxes.filter(function(b){ return b.checked; }); } // A category toggled off (offCats) leaves the session entirely; the text filter is a
// transient browse aid that never changes what's selected. So both manual picks and the
// random pool key off offCats never the search box keeping the copied prompt in step
// with what the user sees, and never starving a random draw because of a stray filter term.
function inScope(b){ return !offCats[b.dataset.cat]; }
function checked(){ return boxes.filter(function(b){ return b.checked && inScope(b); }); }
function update(){ function update(){
$('pickN').textContent = checked().length; $('pickN').textContent = checked().length;
@ -558,11 +587,7 @@ SELECTOR_TEMPLATE = r"""<!DOCTYPE html>
}); });
} }
function visibleUnchecked(){ function randomPool(){ return boxes.filter(function(b){ return !b.checked && inScope(b); }); }
return boxes.filter(function(b){
return !b.checked && b.closest('label.tech').style.display !== 'none';
});
}
function sample(arr, n){ function sample(arr, n){
var a = arr.slice(), out = []; var a = arr.slice(), out = [];
@ -572,7 +597,7 @@ SELECTOR_TEMPLATE = r"""<!DOCTYPE html>
function compose(){ function compose(){
var picks = checked().map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: false }; }); var picks = checked().map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: false }; });
var rnd = sample(visibleUnchecked(), state.rand).map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: true }; }); var rnd = sample(randomPool(), state.rand).map(function(b){ return { n: b.dataset.name, c: b.dataset.cat, d: b.dataset.desc, r: true }; });
var techs = picks.concat(rnd); var techs = picks.concat(rnd);
var L = ["Let's run my brainstorming session.", "", 'Facilitation mode: ' + state.mode + '.']; var L = ["Let's run my brainstorming session.", "", 'Facilitation mode: ' + state.mode + '.'];
if (techs.length){ if (techs.length){
@ -598,16 +623,32 @@ SELECTOR_TEMPLATE = r"""<!DOCTYPE html>
var ta = document.createElement('textarea'); var ta = document.createElement('textarea');
ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0'; ta.value = t; ta.style.position = 'fixed'; ta.style.opacity = '0';
document.body.appendChild(ta); ta.focus(); ta.select(); document.body.appendChild(ta); ta.focus(); ta.select();
try { document.execCommand('copy'); } catch(e){} var ok = false;
try { ok = document.execCommand('copy'); } catch(e){ ok = false; }
document.body.removeChild(ta); document.body.removeChild(ta);
return ok;
}
function flash(ok, text){
var b = $('banner');
b.classList.toggle('fail', !ok);
b.innerHTML = ok
? '✓ Copied! Now paste it into the chat to start your session.'
: '⚠ Couldnt reach the clipboard — copy the text in the box, then paste it into the chat.';
b.classList.add('show');
setTimeout(function(){ b.classList.remove('show'); }, 4500);
// Last resort on a hard failure: a prefilled, selectable prompt so the text is never lost.
if (!ok){ window.prompt('Copy this, then paste it into the chat:', text); }
} }
$('copy').addEventListener('click', function(){ $('copy').addEventListener('click', function(){
var text = compose(); var text = compose();
var show = function(){ var b = $('banner'); b.classList.add('show'); setTimeout(function(){ b.classList.remove('show'); }, 4500); };
if (navigator.clipboard && navigator.clipboard.writeText){ if (navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(text).then(show, function(){ fallbackCopy(text); show(); }); navigator.clipboard.writeText(text).then(
} else { fallbackCopy(text); show(); } function(){ flash(true, text); },
function(){ flash(fallbackCopy(text), text); }
);
} else { flash(fallbackCopy(text), text); }
}); });
update(); update();
@ -665,6 +706,7 @@ def html_doc(rows: list[dict]) -> str:
def main(argv: list[str] | None = None) -> int: def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) 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("--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") p.add_argument("--json", action="store_true", help="emit structured JSON instead of lean text")
sub = p.add_subparsers(dest="cmd", required=True) sub = p.add_subparsers(dest="cmd", required=True)
sub.add_parser("categories", help="list category names + counts") sub.add_parser("categories", help="list category names + counts")
@ -684,6 +726,11 @@ def main(argv: list[str] | None = None) -> int:
print(f"error: technique file not found: {args.file}", file=sys.stderr) print(f"error: technique file not found: {args.file}", file=sys.stderr)
return 2 return 2
rows = load(args.file) 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 csv_dir = args.file.resolve().parent
if args.cmd == "categories": if args.cmd == "categories":
@ -709,7 +756,8 @@ def main(argv: list[str] | None = None) -> int:
if not pool: if not pool:
print("# no techniques match", file=sys.stderr) print("# no techniques match", file=sys.stderr)
return 1 return 1
print(fmt_list(random.sample(pool, min(args.n, len(pool))), args.json)) 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": elif args.cmd == "html":
if not args.out: if not args.out:
print( print(

View File

@ -75,20 +75,28 @@ def memlog_path(workspace: str) -> Path:
def split(text: str) -> tuple[dict, str]: def split(text: str) -> tuple[dict, str]:
"""Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.""" """Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
if not text.startswith("---"):
The closing fence is the first line that is *exactly* `---`, so a `---` inside a
field value (topic/goal are free user text) never truncates the frontmatter.
"""
lines = text.splitlines()
if not lines or lines[0] != "---":
raise ValueError(".memlog.md has no frontmatter") raise ValueError(".memlog.md has no frontmatter")
_, fm, body = text.split("---", 2) end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
if end is None:
raise ValueError(".memlog.md frontmatter is not terminated")
meta: dict[str, str] = {} meta: dict[str, str] = {}
for line in fm.strip().splitlines(): for line in lines[1:end]:
if ":" in line: if ":" in line:
k, v = line.split(":", 1) k, v = line.split(":", 1)
meta[k.strip()] = v.strip() meta[k.strip()] = v.strip()
return meta, body.lstrip("\n") return meta, "\n".join(lines[end + 1:]).lstrip("\n")
def render(meta: dict, body: str) -> str: def render(meta: dict, body: str) -> str:
fm = "\n".join(f"{k}: {v}" for k, v in meta.items()) # Neutralize newlines in values so a multi-line field can't break the fence on re-read.
fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n" return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"

View File

@ -120,7 +120,13 @@ def test_random_respects_n_and_category(lib, capsys):
brain.main(["--file", str(lib), "random", "--category", "wild", "-n", "5"]) brain.main(["--file", str(lib), "random", "--category", "wild", "-n", "5"])
lines = capsys.readouterr().out.strip().splitlines() lines = capsys.readouterr().out.strip().splitlines()
assert len(lines) == 2 # only 2 wild exist, n capped assert len(lines) == 2 # only 2 wild exist, n capped
assert all(l.startswith("wild\t") for l in lines) assert all(line.startswith("wild\t") for line in lines)
def test_random_negative_n_does_not_crash(lib, capsys):
# a negative -n is clamped to 0, not passed to random.sample (which would raise)
assert brain.main(["--file", str(lib), "random", "-n", "-1"]) == 0
assert capsys.readouterr().out.strip() == ""
def test_missing_file_returns_2(tmp_path): def test_missing_file_returns_2(tmp_path):
@ -152,6 +158,53 @@ def test_html_creates_missing_parent(lib, tmp_path):
assert out.is_file() assert out.is_file()
# --- --extra overlay (customize.toml additional_techniques) -------------
EXTRA = (
'[{"category": "domain-specific", "technique_name": "Regulatory Inversion", '
'"description": "Start from the compliance constraint and brainstorm what it unlocks."}, '
'{"category": "wild", "technique_name": "Extra Wild One", "description": "An added wild method."}]'
)
@pytest.fixture
def extra(tmp_path):
p = tmp_path / "extra.json"
p.write_text(EXTRA, encoding="utf-8")
return p
def test_extra_merges_into_categories(lib, extra, capsys):
brain.main(["--file", str(lib), "--extra", str(extra), "categories"])
out = capsys.readouterr().out
assert "domain-specific\t1" in out # a brand-new category appears
assert "wild\t3" in out # the extra wild one is counted alongside the shipped two
def test_extra_appears_in_list_and_random(lib, extra, capsys):
brain.main(["--file", str(lib), "--extra", str(extra), "list", "--category", "domain-specific"])
assert "Regulatory Inversion" in capsys.readouterr().out
def test_extra_is_first_class_in_html(lib, extra, tmp_path):
out = tmp_path / "sel.html"
assert brain.main(["--file", str(lib), "--extra", str(extra), "html", "--out", str(out)]) == 0
doc = out.read_text(encoding="utf-8")
# custom technique is selectable and its new category renders without crashing (fallback glyph/hue)
assert "Regulatory Inversion" in doc
assert "Domain Specific" in doc
def test_extra_missing_file_returns_2(lib, tmp_path):
assert brain.main(["--file", str(lib), "--extra", str(tmp_path / "nope.json"), "categories"]) == 2
def test_unknown_category_style_uses_fallback_glyph():
hue, glyph = brain.category_style("totally-made-up-category")
assert hue.startswith("#") and len(hue) == 7 # valid derived hex
assert glyph == brain._FALLBACK_GLYPH
def test_shipped_selector_is_in_sync_with_catalog(): def test_shipped_selector_is_in_sync_with_catalog():
# foolproofing: if someone edits brain-methods.csv they must regenerate the page. # foolproofing: if someone edits brain-methods.csv they must regenerate the page.
# Regenerate with: python3 brain.py html --out assets/brain-selector.html # Regenerate with: python3 brain.py html --out assets/brain-selector.html

View File

@ -44,10 +44,10 @@ def init(ws, **fields):
assert memlog.main(argv) == 0 assert memlog.main(argv) == 0
def append(ws, text, type=None, by=None): def append(ws, text, entry_type=None, by=None):
argv = ["append", "--workspace", ws, "--text", text] argv = ["append", "--workspace", ws, "--text", text]
if type: if entry_type:
argv += ["--type", type] argv += ["--type", entry_type]
if by: if by:
argv += ["--by", by] argv += ["--by", by]
assert memlog.main(argv) == 0 assert memlog.main(argv) == 0
@ -98,16 +98,16 @@ def test_append_lands_at_end_in_order(ws):
def test_no_sections_or_headings_ever(ws): def test_no_sections_or_headings_ever(ws):
init(ws) init(ws)
append(ws, "started foo", type="technique") append(ws, "started foo", entry_type="technique")
append(ws, "an idea", type="idea") append(ws, "an idea", entry_type="idea")
append(ws, "started bar", type="technique") append(ws, "started bar", entry_type="technique")
assert "## " not in body_of(ws) # the flat log never grows headings assert "## " not in body_of(ws) # the flat log never grows headings
def test_type_renders_as_inline_tag(ws): def test_type_renders_as_inline_tag(ws):
init(ws) init(ws)
append(ws, "the earth revolves around the sun", type="idea") append(ws, "the earth revolves around the sun", entry_type="idea")
append(ws, "how do we handle stampede?", type="question") append(ws, "how do we handle stampede?", entry_type="question")
body = body_of(ws) body = body_of(ws)
assert "- (idea) the earth revolves around the sun" in body assert "- (idea) the earth revolves around the sun" in body
assert "- (question) how do we handle stampede?" in body assert "- (question) how do we handle stampede?" in body
@ -128,12 +128,12 @@ def test_append_collapses_newlines_into_one_line(ws):
def test_revisited_technique_is_just_a_later_entry(ws): def test_revisited_technique_is_just_a_later_entry(ws):
# the user's model: switching techniques is an entry, not a section to return to # the user's model: switching techniques is an entry, not a section to return to
init(ws) init(ws)
append(ws, "started SCAMPER", type="technique") append(ws, "started SCAMPER", entry_type="technique")
append(ws, "magnetic latch", type="idea") append(ws, "magnetic latch", entry_type="idea")
append(ws, "started Six Hats", type="technique") append(ws, "started Six Hats", entry_type="technique")
append(ws, "stale data risk", type="idea") append(ws, "stale data risk", entry_type="idea")
append(ws, "started SCAMPER", type="technique") # back to SCAMPER — just appended again append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again
append(ws, "stackable tiers", type="idea") append(ws, "stackable tiers", entry_type="idea")
assert entries(ws) == [ assert entries(ws) == [
"- (technique) started SCAMPER", "- (technique) started SCAMPER",
"- (idea) magnetic latch", "- (idea) magnetic latch",
@ -147,8 +147,8 @@ def test_revisited_technique_is_just_a_later_entry(ws):
def test_by_renders_attribution_in_tag(ws): def test_by_renders_attribution_in_tag(ws):
# Creative Partner mode must record whose idea each one was # Creative Partner mode must record whose idea each one was
init(ws) init(ws)
append(ws, "magnetic latch lid", type="idea", by="user") append(ws, "magnetic latch lid", entry_type="idea", by="user")
append(ws, "lid doubles as a plate", type="idea", by="coach") append(ws, "lid doubles as a plate", entry_type="idea", by="coach")
body = body_of(ws) body = body_of(ws)
assert "- (idea by user) magnetic latch lid" in body assert "- (idea by user) magnetic latch lid" in body
assert "- (idea by coach) lid doubles as a plate" in body assert "- (idea by coach) lid doubles as a plate" in body
@ -162,10 +162,10 @@ def test_by_without_type_renders_alone(ws):
def test_heterogeneous_entry_types_coexist(ws): def test_heterogeneous_entry_types_coexist(ws):
init(ws) init(ws)
append(ws, "an idea", type="idea") append(ws, "an idea", entry_type="idea")
append(ws, "an open question", type="question") append(ws, "an open question", entry_type="question")
append(ws, "a decision we made", type="decision") append(ws, "a decision we made", entry_type="decision")
append(ws, "user wants mobile-first", type="direction") append(ws, "user wants mobile-first", entry_type="direction")
body = body_of(ws) body = body_of(ws)
for tag in ("(idea)", "(question)", "(decision)", "(direction)"): for tag in ("(idea)", "(question)", "(decision)", "(direction)"):
assert tag in body assert tag in body
@ -181,7 +181,7 @@ def test_set_flips_status(ws):
def test_set_preserves_body(ws): def test_set_preserves_body(ws):
init(ws) init(ws)
append(ws, "keep me", type="idea") append(ws, "keep me", entry_type="idea")
memlog.main(["set", "--workspace", ws, "--key", "status", "--value", "complete"]) memlog.main(["set", "--workspace", ws, "--key", "status", "--value", "complete"])
meta, body = memlog.split(read(ws)) meta, body = memlog.split(read(ws))
assert meta["status"] == "complete" assert meta["status"] == "complete"
@ -205,7 +205,7 @@ def test_updated_stays_last(ws):
def test_roundtrip_render_is_stable(ws): def test_roundtrip_render_is_stable(ws):
init(ws) init(ws)
append(ws, "one", type="idea") append(ws, "one", entry_type="idea")
first = read(ws) first = read(ws)
meta, body = memlog.split(first) meta, body = memlog.split(first)
assert memlog.render(meta, body) == first assert memlog.render(meta, body) == first
@ -213,14 +213,42 @@ def test_roundtrip_render_is_stable(ws):
def test_commas_in_field_survive(ws): def test_commas_in_field_survive(ws):
init(ws, topic="cars, trains, and planes") init(ws, topic="cars, trains, and planes")
append(ws, "z", type="idea") append(ws, "z", entry_type="idea")
meta, _ = memlog.split(read(ws)) meta, _ = memlog.split(read(ws))
assert meta["topic"] == "cars, trains, and planes" assert meta["topic"] == "cars, trains, and planes"
def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws):
# A `---` inside a value must NOT be read as the closing fence: topic stays intact,
# status survives, and the body never leaks frontmatter text.
init(ws, topic="Pricing --- tiers --- and add-ons")
append(ws, "an idea", entry_type="idea")
meta, body = memlog.split(read(ws))
assert meta["topic"] == "Pricing --- tiers --- and add-ons"
assert meta["status"] == "active"
assert entries(ws) == ["- (idea) an idea"]
assert "status:" not in body # frontmatter never bled into the body
def test_triple_dash_status_survives_in_ack(ws, capsys):
init(ws, topic="a --- b")
append(ws, "x", entry_type="idea")
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert out["status"] == "active" # not "" — frontmatter recovered cleanly
def test_newline_in_field_is_neutralized(ws):
# A value carrying a newline can't break the fence on the next round-trip.
memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"])
append(ws, "x", entry_type="idea")
meta, _ = memlog.split(read(ws))
assert "\n" not in meta["topic"]
assert meta["status"] == "active"
def test_append_emits_json_ack(ws, capsys): def test_append_emits_json_ack(ws, capsys):
init(ws) init(ws)
append(ws, "x", type="idea") append(ws, "x", entry_type="idea")
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
assert out["ok"] is True assert out["ok"] is True
assert out["status"] == "active" assert out["status"] == "active"