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:
parent
93794aa0dd
commit
e966110e71
|
|
@ -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 5–10 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 5–10 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 3–4; 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 3–4; 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 (3–4 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 (3–4 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 1–n categories; `random --category` draws the batch from them, so the progression varies session to session. No listing needed.
|
- **Category** — the user names 1–n 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.
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
|
: '⚠ Couldn’t 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();
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.'
|
||||||
|
: '⚠ Couldn’t 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(
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue