bmad-method 6.8.0 → 6.8.1-next.1
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/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-party-mode/SKILL.md +44 -97
- 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,202 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.10"
|
|
4
|
+
# ///
|
|
5
|
+
"""memlog — an append-only memory log: LLM-optimal working memory for a skill.
|
|
6
|
+
|
|
7
|
+
A memlog is the dense, chronological record of everything that mattered in a piece of
|
|
8
|
+
work — every item the user generated or accepted — kept minimal like human memory: only
|
|
9
|
+
what's important, never bloated. It persists ACROSS sessions, so a fresh session can
|
|
10
|
+
load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a
|
|
11
|
+
deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary
|
|
12
|
+
by how it calls `append` — the tool stays neutral.
|
|
13
|
+
|
|
14
|
+
It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded
|
|
15
|
+
at the END in the order it happened. The chronology itself is the structure — an event
|
|
16
|
+
like "started technique X" is just another entry, same as an idea or an insight.
|
|
17
|
+
|
|
18
|
+
Two invariants make it trustworthy:
|
|
19
|
+
|
|
20
|
+
1. Append-only, chronological. Entries land at the end, in the order they happen.
|
|
21
|
+
Nothing is ever inserted backward, reordered, or grouped.
|
|
22
|
+
2. Write-only / blind. Every command is an atomic, context-free write and echoes the
|
|
23
|
+
new state as JSON, so the caller never re-reads the file mid-session. The one time
|
|
24
|
+
the file is read is on resume — and the caller reads it itself, not via this script.
|
|
25
|
+
|
|
26
|
+
The file shape (.memlog.md):
|
|
27
|
+
|
|
28
|
+
---
|
|
29
|
+
topic: Onboarding flow for a budgeting app
|
|
30
|
+
goal: lift week-1 retention
|
|
31
|
+
status: active
|
|
32
|
+
updated: 2026-05-30T14:22
|
|
33
|
+
---
|
|
34
|
+
|
|
35
|
+
- (note) user picked techniques: SCAMPER, then Six Thinking Hats
|
|
36
|
+
- (technique) started SCAMPER
|
|
37
|
+
- (idea) skip the signup wall: let people try with sample data first
|
|
38
|
+
- (idea) auto-import one bank account so the first screen shows real numbers
|
|
39
|
+
- (question) is open-banking consent too heavy for step one?
|
|
40
|
+
- (technique) started Six Thinking Hats
|
|
41
|
+
- (idea) black-hat: imported transactions look scary before they're categorized
|
|
42
|
+
- (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
|
|
43
|
+
- (direction) user wants to optimize for the anxious first-timer, not the power user
|
|
44
|
+
- (decision) lead with one pre-categorized account; defer multi-account import
|
|
45
|
+
|
|
46
|
+
Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
|
|
47
|
+
decision, technique, …) — and an optional `--by` naming who it came from (e.g. `user`,
|
|
48
|
+
`coach`), for sessions where authorship matters. Both render into one short inline tag:
|
|
49
|
+
`(idea)`, `(idea by user)`, `(by coach)`. Omit them for a plain note. The host skill
|
|
50
|
+
names the vocabulary; the script does not.
|
|
51
|
+
|
|
52
|
+
Commands:
|
|
53
|
+
init --workspace DIR [--field k=v ...] create the memlog (errors if it exists)
|
|
54
|
+
append --workspace DIR --text STR [--type T] [--by W] append one entry at the end
|
|
55
|
+
set --workspace DIR --key K --value V set/replace a frontmatter field
|
|
56
|
+
|
|
57
|
+
The workspace is the run folder; the memlog is always {workspace}/.memlog.md.
|
|
58
|
+
"""
|
|
59
|
+
import argparse
|
|
60
|
+
import json
|
|
61
|
+
import os
|
|
62
|
+
import sys
|
|
63
|
+
from datetime import datetime
|
|
64
|
+
from pathlib import Path
|
|
65
|
+
|
|
66
|
+
MEMLOG = ".memlog.md"
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def now() -> str:
|
|
70
|
+
return datetime.now().strftime("%Y-%m-%dT%H:%M")
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def memlog_path(workspace: str) -> Path:
|
|
74
|
+
return Path(workspace) / MEMLOG
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def split(text: str) -> tuple[dict, str]:
|
|
78
|
+
"""Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
|
|
79
|
+
|
|
80
|
+
The closing fence is the first line that is *exactly* `---`, so a `---` inside a
|
|
81
|
+
field value (topic/goal are free user text) never truncates the frontmatter.
|
|
82
|
+
"""
|
|
83
|
+
lines = text.splitlines()
|
|
84
|
+
if not lines or lines[0] != "---":
|
|
85
|
+
raise ValueError(".memlog.md has no frontmatter")
|
|
86
|
+
end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
|
|
87
|
+
if end is None:
|
|
88
|
+
raise ValueError(".memlog.md frontmatter is not terminated")
|
|
89
|
+
meta: dict[str, str] = {}
|
|
90
|
+
for line in lines[1:end]:
|
|
91
|
+
if ":" in line:
|
|
92
|
+
k, v = line.split(":", 1)
|
|
93
|
+
meta[k.strip()] = v.strip()
|
|
94
|
+
return meta, "\n".join(lines[end + 1:]).lstrip("\n")
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render(meta: dict, body: str) -> str:
|
|
98
|
+
# Neutralize newlines in values so a multi-line field can't break the fence on re-read.
|
|
99
|
+
fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
|
|
100
|
+
return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def touch(meta: dict) -> None:
|
|
104
|
+
"""Stamp `updated` and keep it last so the field order stays predictable."""
|
|
105
|
+
meta.pop("updated", None)
|
|
106
|
+
meta["updated"] = now()
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def write_atomic(path: Path, text: str) -> None:
|
|
110
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
111
|
+
tmp.write_text(text, encoding="utf-8")
|
|
112
|
+
os.replace(tmp, path)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def entry_count(body: str) -> int:
|
|
116
|
+
return sum(1 for ln in body.splitlines() if ln.startswith("- "))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def ack(path: Path, meta: dict, body: str) -> None:
|
|
120
|
+
"""Echo new state so the caller never re-reads the file to know where it stands."""
|
|
121
|
+
print(json.dumps({
|
|
122
|
+
"ok": True,
|
|
123
|
+
"memlog": str(path),
|
|
124
|
+
"status": meta.get("status", ""),
|
|
125
|
+
"entries": entry_count(body),
|
|
126
|
+
}))
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def cmd_init(args) -> int:
|
|
130
|
+
path = memlog_path(args.workspace)
|
|
131
|
+
if path.exists():
|
|
132
|
+
print(f"error: {path} already exists; use append/set to update it", file=sys.stderr)
|
|
133
|
+
return 2
|
|
134
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
135
|
+
meta: dict[str, str] = {}
|
|
136
|
+
for pair in args.field or []:
|
|
137
|
+
if "=" not in pair:
|
|
138
|
+
print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr)
|
|
139
|
+
return 2
|
|
140
|
+
k, v = pair.split("=", 1)
|
|
141
|
+
meta[k.strip()] = v.strip()
|
|
142
|
+
meta.setdefault("status", "active")
|
|
143
|
+
touch(meta)
|
|
144
|
+
write_atomic(path, render(meta, ""))
|
|
145
|
+
ack(path, meta, "")
|
|
146
|
+
return 0
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def cmd_append(args) -> int:
|
|
150
|
+
path = memlog_path(args.workspace)
|
|
151
|
+
meta, body = split(path.read_text(encoding="utf-8"))
|
|
152
|
+
text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat
|
|
153
|
+
label = args.type or ""
|
|
154
|
+
if args.by:
|
|
155
|
+
label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)"
|
|
156
|
+
tag = f"({label}) " if label else ""
|
|
157
|
+
entry = f"- {tag}{text}"
|
|
158
|
+
body = (body.rstrip("\n") + "\n" + entry) if body.strip() else entry # always at the end
|
|
159
|
+
touch(meta)
|
|
160
|
+
write_atomic(path, render(meta, body))
|
|
161
|
+
ack(path, meta, body)
|
|
162
|
+
return 0
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def cmd_set(args) -> int:
|
|
166
|
+
path = memlog_path(args.workspace)
|
|
167
|
+
meta, body = split(path.read_text(encoding="utf-8"))
|
|
168
|
+
meta[args.key] = args.value
|
|
169
|
+
touch(meta)
|
|
170
|
+
write_atomic(path, render(meta, body))
|
|
171
|
+
ack(path, meta, body)
|
|
172
|
+
return 0
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def main(argv: list[str] | None = None) -> int:
|
|
176
|
+
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
|
177
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
178
|
+
|
|
179
|
+
pi = sub.add_parser("init", help="create the memlog")
|
|
180
|
+
pi.add_argument("--workspace", required=True)
|
|
181
|
+
pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)")
|
|
182
|
+
pi.set_defaults(func=cmd_init)
|
|
183
|
+
|
|
184
|
+
pa = sub.add_parser("append", help="append one entry at the end")
|
|
185
|
+
pa.add_argument("--workspace", required=True)
|
|
186
|
+
pa.add_argument("--text", required=True)
|
|
187
|
+
pa.add_argument("--type", help="entry kind, rendered as an inline tag")
|
|
188
|
+
pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag")
|
|
189
|
+
pa.set_defaults(func=cmd_append)
|
|
190
|
+
|
|
191
|
+
pset = sub.add_parser("set", help="set a frontmatter field")
|
|
192
|
+
pset.add_argument("--workspace", required=True)
|
|
193
|
+
pset.add_argument("--key", required=True)
|
|
194
|
+
pset.add_argument("--value", required=True)
|
|
195
|
+
pset.set_defaults(func=cmd_set)
|
|
196
|
+
|
|
197
|
+
args = p.parse_args(argv)
|
|
198
|
+
return args.func(args)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
if __name__ == "__main__":
|
|
202
|
+
sys.exit(main())
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["pytest>=8.0"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Tests for brain.py. Run: uv run -m pytest scripts/tests/test_brain.py"""
|
|
6
|
+
import sys
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
12
|
+
import brain # noqa: E402
|
|
13
|
+
|
|
14
|
+
CSV = """category,technique_name,description,detail
|
|
15
|
+
collaborative,Yes And Building,Build on every idea with "yes and" to keep momentum,
|
|
16
|
+
wild,Quantum Superposition,Hold contradictory ideas as simultaneously true,techniques/quantum.md
|
|
17
|
+
structured,SCAMPER Method,Run the idea through seven transformation lenses,
|
|
18
|
+
wild,Anti-Solution,Brainstorm how to make the problem worse then invert,
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
DETAIL = "# Quantum Superposition\nFull multi-step instructions for the complex technique."
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@pytest.fixture
|
|
25
|
+
def lib(tmp_path):
|
|
26
|
+
csv_path = tmp_path / "brain-methods.csv"
|
|
27
|
+
csv_path.write_text(CSV, encoding="utf-8")
|
|
28
|
+
(tmp_path / "techniques").mkdir()
|
|
29
|
+
(tmp_path / "techniques" / "quantum.md").write_text(DETAIL, encoding="utf-8")
|
|
30
|
+
return csv_path
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_load_normalizes_detail(lib):
|
|
34
|
+
rows = brain.load(lib)
|
|
35
|
+
assert len(rows) == 4
|
|
36
|
+
assert rows[0]["detail"] == ""
|
|
37
|
+
assert rows[1]["detail"] == "techniques/quantum.md"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_categories_counts_sorted(lib):
|
|
41
|
+
assert brain.categories(brain.load(lib)) == [("collaborative", 1), ("structured", 1), ("wild", 2)]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_filter_is_case_insensitive(lib):
|
|
45
|
+
rows = brain.filter_cats(brain.load(lib), ["WILD"])
|
|
46
|
+
assert {r["technique_name"] for r in rows} == {"Quantum Superposition", "Anti-Solution"}
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def test_filter_none_returns_all(lib):
|
|
50
|
+
assert len(brain.filter_cats(brain.load(lib), None)) == 4
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def test_find_hits_and_misses(lib):
|
|
54
|
+
found, missing = brain.find(brain.load(lib), ["scamper method", "Nope"])
|
|
55
|
+
assert [r["technique_name"] for r in found] == ["SCAMPER Method"]
|
|
56
|
+
assert missing == ["Nope"]
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def test_resolve_detail_present(lib):
|
|
60
|
+
row = next(r for r in brain.load(lib) if r["detail"])
|
|
61
|
+
assert "multi-step instructions" in brain.resolve_detail(row, lib.parent)
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_resolve_detail_absent_is_none(lib):
|
|
65
|
+
row = next(r for r in brain.load(lib) if not r["detail"])
|
|
66
|
+
assert brain.resolve_detail(row, lib.parent) is None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_resolve_detail_missing_file_warns_not_fatal(lib, capsys):
|
|
70
|
+
rows = brain.load(lib)
|
|
71
|
+
rows[1]["detail"] = "techniques/gone.md"
|
|
72
|
+
assert brain.resolve_detail(rows[1], lib.parent) is None
|
|
73
|
+
assert "not found" in capsys.readouterr().err
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def test_show_inlines_detail(lib, capsys):
|
|
77
|
+
assert brain.main(["--file", str(lib), "show", "Quantum Superposition"]) == 0
|
|
78
|
+
out = capsys.readouterr().out
|
|
79
|
+
assert "multi-step instructions" in out and "[wild]" in out
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def test_show_simple_has_no_detail(lib, capsys):
|
|
83
|
+
brain.main(["--file", str(lib), "show", "SCAMPER Method"])
|
|
84
|
+
out = capsys.readouterr().out
|
|
85
|
+
assert "transformation lenses" in out
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def test_show_all_missing_returns_1(lib):
|
|
89
|
+
assert brain.main(["--file", str(lib), "show", "Ghost"]) == 1
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_list_filtered_text(lib, capsys):
|
|
93
|
+
brain.main(["--file", str(lib), "list", "--category", "structured"])
|
|
94
|
+
out = capsys.readouterr().out.strip().splitlines()
|
|
95
|
+
assert len(out) == 1 and out[0].startswith("structured\tSCAMPER Method\t")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_list_bare_is_refused(lib, capsys):
|
|
99
|
+
# the footgun: bare `list` must NOT dump the catalog into context
|
|
100
|
+
assert brain.main(["--file", str(lib), "list"]) == 2
|
|
101
|
+
captured = capsys.readouterr()
|
|
102
|
+
assert captured.out == "" # nothing leaked to stdout
|
|
103
|
+
assert "--category" in captured.err and "--all" in captured.err
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def test_list_all_dumps_everything(lib, capsys):
|
|
107
|
+
assert brain.main(["--file", str(lib), "list", "--all"]) == 0
|
|
108
|
+
out = capsys.readouterr().out.strip().splitlines()
|
|
109
|
+
assert len(out) == 4 # the deliberate full-catalog escape hatch
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_json_output(lib, capsys):
|
|
113
|
+
import json
|
|
114
|
+
brain.main(["--file", str(lib), "--json", "categories"])
|
|
115
|
+
data = json.loads(capsys.readouterr().out)
|
|
116
|
+
assert {"category": "wild", "count": 2} in data
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_random_respects_n_and_category(lib, capsys):
|
|
120
|
+
brain.main(["--file", str(lib), "random", "--category", "wild", "-n", "5"])
|
|
121
|
+
lines = capsys.readouterr().out.strip().splitlines()
|
|
122
|
+
assert len(lines) == 2 # only 2 wild exist, n capped
|
|
123
|
+
assert all(line.startswith("wild\t") for line in lines)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_random_negative_n_does_not_crash(lib, capsys):
|
|
127
|
+
# a negative -n is clamped to 0, not passed to random.sample (which would raise)
|
|
128
|
+
assert brain.main(["--file", str(lib), "random", "-n", "-1"]) == 0
|
|
129
|
+
assert capsys.readouterr().out.strip() == ""
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_missing_file_returns_2(tmp_path):
|
|
133
|
+
assert brain.main(["--file", str(tmp_path / "nope.csv"), "categories"]) == 2
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
# --- html selection page ------------------------------------------------
|
|
137
|
+
|
|
138
|
+
def test_html_requires_out(lib, capsys):
|
|
139
|
+
# never dump the catalog to stdout — writing to a file is the whole point
|
|
140
|
+
assert brain.main(["--file", str(lib), "html"]) == 2
|
|
141
|
+
assert "--out" in capsys.readouterr().err
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def test_html_writes_selection_page(lib, tmp_path):
|
|
145
|
+
out = tmp_path / "sel.html"
|
|
146
|
+
assert brain.main(["--file", str(lib), "html", "--out", str(out)]) == 0
|
|
147
|
+
doc = out.read_text(encoding="utf-8")
|
|
148
|
+
assert doc.startswith("<!DOCTYPE html>")
|
|
149
|
+
assert "BMad Method Brainstorming Selection" in doc
|
|
150
|
+
for r in brain.load(lib):
|
|
151
|
+
assert r["technique_name"] in doc # every technique is selectable
|
|
152
|
+
assert ""yes and"" in doc # quotes in a description are escaped, not raw
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def test_html_creates_missing_parent(lib, tmp_path):
|
|
156
|
+
out = tmp_path / "nested" / "deep" / "sel.html"
|
|
157
|
+
assert brain.main(["--file", str(lib), "html", "--out", str(out)]) == 0
|
|
158
|
+
assert out.is_file()
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
# --- --extra overlay (customize.toml additional_techniques) -------------
|
|
162
|
+
|
|
163
|
+
EXTRA = (
|
|
164
|
+
'[{"category": "domain-specific", "technique_name": "Regulatory Inversion", '
|
|
165
|
+
'"description": "Start from the compliance constraint and brainstorm what it unlocks."}, '
|
|
166
|
+
'{"category": "wild", "technique_name": "Extra Wild One", "description": "An added wild method."}]'
|
|
167
|
+
)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
@pytest.fixture
|
|
171
|
+
def extra(tmp_path):
|
|
172
|
+
p = tmp_path / "extra.json"
|
|
173
|
+
p.write_text(EXTRA, encoding="utf-8")
|
|
174
|
+
return p
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def test_extra_merges_into_categories(lib, extra, capsys):
|
|
178
|
+
brain.main(["--file", str(lib), "--extra", str(extra), "categories"])
|
|
179
|
+
out = capsys.readouterr().out
|
|
180
|
+
assert "domain-specific\t1" in out # a brand-new category appears
|
|
181
|
+
assert "wild\t3" in out # the extra wild one is counted alongside the shipped two
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_extra_appears_in_list_and_random(lib, extra, capsys):
|
|
185
|
+
brain.main(["--file", str(lib), "--extra", str(extra), "list", "--category", "domain-specific"])
|
|
186
|
+
assert "Regulatory Inversion" in capsys.readouterr().out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def test_extra_is_first_class_in_html(lib, extra, tmp_path):
|
|
190
|
+
out = tmp_path / "sel.html"
|
|
191
|
+
assert brain.main(["--file", str(lib), "--extra", str(extra), "html", "--out", str(out)]) == 0
|
|
192
|
+
doc = out.read_text(encoding="utf-8")
|
|
193
|
+
# custom technique is selectable and its new category renders without crashing (fallback glyph/hue)
|
|
194
|
+
assert "Regulatory Inversion" in doc
|
|
195
|
+
assert "Domain Specific" in doc
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def test_extra_missing_file_returns_2(lib, tmp_path):
|
|
199
|
+
assert brain.main(["--file", str(lib), "--extra", str(tmp_path / "nope.json"), "categories"]) == 2
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def test_unknown_category_style_uses_fallback_glyph():
|
|
203
|
+
hue, glyph = brain.category_style("totally-made-up-category")
|
|
204
|
+
assert hue.startswith("#") and len(hue) == 7 # valid derived hex
|
|
205
|
+
assert glyph == brain._FALLBACK_GLYPH
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def test_shipped_selector_is_in_sync_with_catalog():
|
|
209
|
+
# foolproofing: if someone edits brain-methods.csv they must regenerate the page.
|
|
210
|
+
# Regenerate with: python3 brain.py html --out assets/brain-selector.html
|
|
211
|
+
asset = brain.DEFAULT_FILE.parent / "brain-selector.html"
|
|
212
|
+
assert asset.is_file(), "missing assets/brain-selector.html — generate it"
|
|
213
|
+
expected = brain.html_doc(brain.load(brain.DEFAULT_FILE))
|
|
214
|
+
assert asset.read_text(encoding="utf-8") == expected, (
|
|
215
|
+
"assets/brain-selector.html is stale; regenerate: "
|
|
216
|
+
"python3 brain.py html --out assets/brain-selector.html"
|
|
217
|
+
)
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# /// script
|
|
2
|
+
# requires-python = ">=3.10"
|
|
3
|
+
# dependencies = ["pytest>=8.0"]
|
|
4
|
+
# ///
|
|
5
|
+
"""Tests for memlog.py. Run: uv run --with pytest pytest scripts/tests/test_memlog.py
|
|
6
|
+
|
|
7
|
+
The spine under test is the flat, append-only, chronological invariant: every entry is
|
|
8
|
+
one line recorded at the end in the order it happened — no sections, no grouping.
|
|
9
|
+
"""
|
|
10
|
+
import json
|
|
11
|
+
import sys
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
|
|
17
|
+
import memlog # noqa: E402
|
|
18
|
+
|
|
19
|
+
MEMLOG = ".memlog.md"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def ws(tmp_path):
|
|
24
|
+
return str(tmp_path)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def read(ws):
|
|
28
|
+
return (Path(ws) / MEMLOG).read_text(encoding="utf-8")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def body_of(ws):
|
|
32
|
+
return memlog.split(read(ws))[1]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def entries(ws):
|
|
36
|
+
return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")]
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def init(ws, **fields):
|
|
40
|
+
fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"}
|
|
41
|
+
argv = ["init", "--workspace", ws]
|
|
42
|
+
for k, v in fields.items():
|
|
43
|
+
argv += ["--field", f"{k}={v}"]
|
|
44
|
+
assert memlog.main(argv) == 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def append(ws, text, entry_type=None, by=None):
|
|
48
|
+
argv = ["append", "--workspace", ws, "--text", text]
|
|
49
|
+
if entry_type:
|
|
50
|
+
argv += ["--type", entry_type]
|
|
51
|
+
if by:
|
|
52
|
+
argv += ["--by", by]
|
|
53
|
+
assert memlog.main(argv) == 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
# --- init ---------------------------------------------------------------
|
|
57
|
+
|
|
58
|
+
def test_init_writes_frontmatter_fields(ws):
|
|
59
|
+
init(ws)
|
|
60
|
+
meta, body = memlog.split(read(ws))
|
|
61
|
+
assert meta["topic"] == "Reinvent the lunchbox"
|
|
62
|
+
assert meta["goal"] == "ideas for a pitch"
|
|
63
|
+
assert meta["status"] == "active"
|
|
64
|
+
assert "updated" in meta
|
|
65
|
+
assert body.strip() == ""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def test_init_arbitrary_fields(ws):
|
|
69
|
+
init(ws, topic="T", audience="board")
|
|
70
|
+
meta, _ = memlog.split(read(ws))
|
|
71
|
+
assert meta["audience"] == "board"
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_init_refuses_overwrite(ws):
|
|
75
|
+
init(ws)
|
|
76
|
+
assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_init_creates_missing_workspace(tmp_path):
|
|
80
|
+
nested = str(tmp_path / "a" / "b")
|
|
81
|
+
assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0
|
|
82
|
+
assert (Path(nested) / MEMLOG).is_file()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def test_init_rejects_malformed_field(ws):
|
|
86
|
+
assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# --- append: flat chronological order is the whole point -----------------
|
|
90
|
+
|
|
91
|
+
def test_append_lands_at_end_in_order(ws):
|
|
92
|
+
init(ws)
|
|
93
|
+
append(ws, "first")
|
|
94
|
+
append(ws, "second")
|
|
95
|
+
append(ws, "third")
|
|
96
|
+
assert entries(ws) == ["- first", "- second", "- third"]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_no_sections_or_headings_ever(ws):
|
|
100
|
+
init(ws)
|
|
101
|
+
append(ws, "started foo", entry_type="technique")
|
|
102
|
+
append(ws, "an idea", entry_type="idea")
|
|
103
|
+
append(ws, "started bar", entry_type="technique")
|
|
104
|
+
assert "## " not in body_of(ws) # the flat log never grows headings
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def test_type_renders_as_inline_tag(ws):
|
|
108
|
+
init(ws)
|
|
109
|
+
append(ws, "the earth revolves around the sun", entry_type="idea")
|
|
110
|
+
append(ws, "how do we handle stampede?", entry_type="question")
|
|
111
|
+
body = body_of(ws)
|
|
112
|
+
assert "- (idea) the earth revolves around the sun" in body
|
|
113
|
+
assert "- (question) how do we handle stampede?" in body
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_append_without_type_is_plain_note(ws):
|
|
117
|
+
init(ws)
|
|
118
|
+
append(ws, "bare entry")
|
|
119
|
+
assert entries(ws) == ["- bare entry"]
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def test_append_collapses_newlines_into_one_line(ws):
|
|
123
|
+
init(ws)
|
|
124
|
+
append(ws, "line one\nline two\n spaced out")
|
|
125
|
+
assert entries(ws) == ["- line one line two spaced out"]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def test_revisited_technique_is_just_a_later_entry(ws):
|
|
129
|
+
# the user's model: switching techniques is an entry, not a section to return to
|
|
130
|
+
init(ws)
|
|
131
|
+
append(ws, "started SCAMPER", entry_type="technique")
|
|
132
|
+
append(ws, "magnetic latch", entry_type="idea")
|
|
133
|
+
append(ws, "started Six Hats", entry_type="technique")
|
|
134
|
+
append(ws, "stale data risk", entry_type="idea")
|
|
135
|
+
append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again
|
|
136
|
+
append(ws, "stackable tiers", entry_type="idea")
|
|
137
|
+
assert entries(ws) == [
|
|
138
|
+
"- (technique) started SCAMPER",
|
|
139
|
+
"- (idea) magnetic latch",
|
|
140
|
+
"- (technique) started Six Hats",
|
|
141
|
+
"- (idea) stale data risk",
|
|
142
|
+
"- (technique) started SCAMPER",
|
|
143
|
+
"- (idea) stackable tiers",
|
|
144
|
+
]
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def test_by_renders_attribution_in_tag(ws):
|
|
148
|
+
# Creative Partner mode must record whose idea each one was
|
|
149
|
+
init(ws)
|
|
150
|
+
append(ws, "magnetic latch lid", entry_type="idea", by="user")
|
|
151
|
+
append(ws, "lid doubles as a plate", entry_type="idea", by="coach")
|
|
152
|
+
body = body_of(ws)
|
|
153
|
+
assert "- (idea by user) magnetic latch lid" in body
|
|
154
|
+
assert "- (idea by coach) lid doubles as a plate" in body
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_by_without_type_renders_alone(ws):
|
|
158
|
+
init(ws)
|
|
159
|
+
append(ws, "off-the-cuff thought", by="coach")
|
|
160
|
+
assert entries(ws) == ["- (by coach) off-the-cuff thought"]
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def test_heterogeneous_entry_types_coexist(ws):
|
|
164
|
+
init(ws)
|
|
165
|
+
append(ws, "an idea", entry_type="idea")
|
|
166
|
+
append(ws, "an open question", entry_type="question")
|
|
167
|
+
append(ws, "a decision we made", entry_type="decision")
|
|
168
|
+
append(ws, "user wants mobile-first", entry_type="direction")
|
|
169
|
+
body = body_of(ws)
|
|
170
|
+
for tag in ("(idea)", "(question)", "(decision)", "(direction)"):
|
|
171
|
+
assert tag in body
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# --- set ----------------------------------------------------------------
|
|
175
|
+
|
|
176
|
+
def test_set_flips_status(ws):
|
|
177
|
+
init(ws)
|
|
178
|
+
memlog.main(["set", "--workspace", ws, "--key", "status", "--value", "complete"])
|
|
179
|
+
assert memlog.split(read(ws))[0]["status"] == "complete"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def test_set_preserves_body(ws):
|
|
183
|
+
init(ws)
|
|
184
|
+
append(ws, "keep me", entry_type="idea")
|
|
185
|
+
memlog.main(["set", "--workspace", ws, "--key", "status", "--value", "complete"])
|
|
186
|
+
meta, body = memlog.split(read(ws))
|
|
187
|
+
assert meta["status"] == "complete"
|
|
188
|
+
assert "- (idea) keep me" in body
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def test_set_can_add_new_field(ws):
|
|
192
|
+
init(ws)
|
|
193
|
+
memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"])
|
|
194
|
+
assert memlog.split(read(ws))[0]["owner"] == "BMad"
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def test_updated_stays_last(ws):
|
|
198
|
+
init(ws)
|
|
199
|
+
memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"])
|
|
200
|
+
meta = memlog.split(read(ws))[0]
|
|
201
|
+
assert list(meta)[-1] == "updated"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# --- robustness ---------------------------------------------------------
|
|
205
|
+
|
|
206
|
+
def test_roundtrip_render_is_stable(ws):
|
|
207
|
+
init(ws)
|
|
208
|
+
append(ws, "one", entry_type="idea")
|
|
209
|
+
first = read(ws)
|
|
210
|
+
meta, body = memlog.split(first)
|
|
211
|
+
assert memlog.render(meta, body) == first
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def test_commas_in_field_survive(ws):
|
|
215
|
+
init(ws, topic="cars, trains, and planes")
|
|
216
|
+
append(ws, "z", entry_type="idea")
|
|
217
|
+
meta, _ = memlog.split(read(ws))
|
|
218
|
+
assert meta["topic"] == "cars, trains, and planes"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws):
|
|
222
|
+
# A `---` inside a value must NOT be read as the closing fence: topic stays intact,
|
|
223
|
+
# status survives, and the body never leaks frontmatter text.
|
|
224
|
+
init(ws, topic="Pricing --- tiers --- and add-ons")
|
|
225
|
+
append(ws, "an idea", entry_type="idea")
|
|
226
|
+
meta, body = memlog.split(read(ws))
|
|
227
|
+
assert meta["topic"] == "Pricing --- tiers --- and add-ons"
|
|
228
|
+
assert meta["status"] == "active"
|
|
229
|
+
assert entries(ws) == ["- (idea) an idea"]
|
|
230
|
+
assert "status:" not in body # frontmatter never bled into the body
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def test_triple_dash_status_survives_in_ack(ws, capsys):
|
|
234
|
+
init(ws, topic="a --- b")
|
|
235
|
+
append(ws, "x", entry_type="idea")
|
|
236
|
+
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
|
237
|
+
assert out["status"] == "active" # not "" — frontmatter recovered cleanly
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def test_newline_in_field_is_neutralized(ws):
|
|
241
|
+
# A value carrying a newline can't break the fence on the next round-trip.
|
|
242
|
+
memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"])
|
|
243
|
+
append(ws, "x", entry_type="idea")
|
|
244
|
+
meta, _ = memlog.split(read(ws))
|
|
245
|
+
assert "\n" not in meta["topic"]
|
|
246
|
+
assert meta["status"] == "active"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def test_append_emits_json_ack(ws, capsys):
|
|
250
|
+
init(ws)
|
|
251
|
+
append(ws, "x", entry_type="idea")
|
|
252
|
+
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
|
253
|
+
assert out["ok"] is True
|
|
254
|
+
assert out["status"] == "active"
|
|
255
|
+
assert out["entries"] == 1
|
|
256
|
+
assert out["memlog"].endswith(MEMLOG)
|
|
257
|
+
assert "section" not in out # sections are gone
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def test_ack_entry_count_climbs(ws, capsys):
|
|
261
|
+
init(ws)
|
|
262
|
+
append(ws, "a")
|
|
263
|
+
append(ws, "b")
|
|
264
|
+
out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
|
|
265
|
+
assert out["entries"] == 2
|