bmad-method 6.8.1-next.6 → 6.8.1-next.8

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://json.schemastore.org/package.json",
3
3
  "name": "bmad-method",
4
- "version": "6.8.1-next.6",
4
+ "version": "6.8.1-next.8",
5
5
  "description": "Breakthrough Method of Agile AI-driven Development",
6
6
  "keywords": [
7
7
  "agile",
@@ -43,15 +43,27 @@ Inside the spec folder:
43
43
 
44
44
  ```
45
45
  <spec-folder>/
46
- SPEC.md ← uppercase, the kernel
47
- <companion-1>.md ← optional, content-typed (e.g. glossary.md)
46
+ SPEC.md ← uppercase, the kernel — DERIVED from .memlog.md, never hand-edited
47
+ <companion-1>.md ← optional, content-typed (e.g. glossary.md); spec-authored ones are derived too
48
48
  <companion-2>.md
49
- .decision-log.md ← canonical memory for this spec
49
+ .memlog.md ← canonical, append-only memory; what SPEC.md is distilled from
50
50
  ```
51
51
 
52
+ ## Memory and derivation
53
+
54
+ `.memlog.md` is canonical — an append-only, chronological record of every decision, constraint, capability (with its stable `CAP-N`), assumption, open question, and bit of user direction, one line each in the order it happened, never edited or reordered. `SPEC.md` and every spec-authored companion are **derived on each run** from the memlog (the decision-of-record) plus the sources it cites for raw content — never hand-patched.
55
+
56
+ Deriving the contract from a living log instead of editing the contract in place is what lets the steps around the spec (PRD, UX, architecture, epics) run in any order and feed the same spec without merge drift: the log only accumulates, the artifact is re-rendered. So the spec is updated *only* by re-deriving it here — bmad-spec is its single writer; a hand-edit to `SPEC.md` from outside is unsupported and is overwritten on the next derive.
57
+
58
+ Writes go through the shared script — `{project-root}/_bmad/scripts/memlog.py`, the same location as `resolve_customization.py` (atomic; never read it back except to resume):
59
+
60
+ - `python3 {project-root}/_bmad/scripts/memlog.py init --workspace {spec-folder} --field topic="<what is being specced>"` — once, at create.
61
+ - `python3 {project-root}/_bmad/scripts/memlog.py append --workspace {spec-folder} --type <decision|constraint|capability|assumption|question|direction|note|event> --text "<one-line gist, reason included>"` — as each lands.
62
+ - Terminal moments (a validation verdict, "spec finalized") are `--type event` entries; the memlog carries no status field.
63
+
52
64
  ## The Operation
53
65
 
54
- Read the input and its ancillary linked materials. If there is no input, follow the no-input branch in **Workspace** (ask or block). If a prior `SPEC.md` exists at the target folder, read it too — the operation becomes an update. Preserve capability IDs; new capabilities get the next unused `CAP-N`; never reuse retired IDs. Otherwise this is a create.
66
+ Read the input and its ancillary linked materials. If there is no input, follow the no-input branch in **Workspace** (ask or block). If a prior `.memlog.md` exists at the target folder, read it — the operation becomes an update, and the memlog (not the rendered `SPEC.md`) is the authority on what was decided and on capability IDs. Preserve those IDs; new capabilities get the next unused `CAP-N`; never reuse retired IDs. Otherwise this is a create, and the first move is `memlog.py init`.
55
67
 
56
68
  When the input is structured and pre-sorted (a PRD with an addendum, a GDD, a brief produced by an upstream BMad skill), trust the authored separation: lift kernel-fitting content into SPEC.md, lift overflow into appropriately-named companions. When the input is mixed (a brain dump, a transcript, an RFC, a customer email), do the sorting yourself: walk each claim, apply the three-lens load-bearing test (Spec Law rule 7), and route to the kernel field or a companion.
57
69
 
@@ -59,6 +71,8 @@ Distill the input into the five-field kernel using `{workflow.spec_template}` as
59
71
 
60
72
  Write lean from the first pass: every sentence must earn its place. Decoration costs tokens and dilutes downstream readers.
61
73
 
74
+ Log each decision, capability, constraint, and accepted change to `.memlog.md` as it is made — that running record is what the render reads. Because the log is append-only, a later entry supersedes an earlier one on the same point while the history stays intact. When two currently-live sources or companions disagree on the same field, or an either/or never got resolved, surface it to the user rather than silently choosing — the resolution is itself a new memlog entry.
75
+
62
76
  If the input is genuinely too thin to distill (e.g. "an app for hikers" with no surrounding context), stop and suggest `bmad-prd` (or sibling ceremony skill). This skill distills; it does not coach.
63
77
 
64
78
  ## Load-bearing
@@ -94,7 +108,7 @@ Every spec must satisfy these eight rules. The operation aims for them; the self
94
108
  5. **Success signal is concrete enough to test or demonstrate against.** "Users love it" doesn't qualify.
95
109
  6. **Capability IDs are stable and unique.** Never reused, never renumbered.
96
110
  7. **Preservation.** Every load-bearing source claim lands in SPEC.md or a companion. Wrapper ceremony does not.
97
- 8. **Lean prose.** Every sentence carries load-bearing content. Cut decoration, hedges, backstory, throat-clearing. Applies to SPEC.md, companions, and `.decision-log.md`.
111
+ 8. **Lean prose.** Every sentence carries load-bearing content. Cut decoration, hedges, backstory, throat-clearing. Applies to SPEC.md, companions, and `.memlog.md`.
98
112
 
99
113
  ## Self-Validate
100
114
 
@@ -104,7 +118,7 @@ After every create or update, sweep the resulting artifact in **two passes** bef
104
118
 
105
119
  **Pass 2 — Preservation.** Walk the source claim by claim. Confirm each load-bearing claim landed in SPEC.md or a companion. Wrapper-ceremony drops are logged under "Wrapper-only content" so the drop is on the record, not silent.
106
120
 
107
- Append a one-paragraph verdict to `.decision-log.md` covering both passes. In interactive mode, review the verdict with the user. In headless mode, `.decision-log.md` is one of the files returned, so the caller (or its downstream LLM) reads the verdict there.
121
+ Record the verdict for each pass to `.memlog.md` (`append --type event`). In interactive mode, review it with the user. In headless mode, `.memlog.md` is one of the files returned, so the caller (or its downstream LLM) reads the verdict there.
108
122
 
109
123
  ## Spec with no change signal
110
124
 
@@ -120,10 +134,10 @@ Run `{workflow.on_complete}` if set.
120
134
 
121
135
  ## After Spec is Output
122
136
 
123
- Any update to spec regarding assumptions, open questions, or other changes should be appended to that source's decision log also and offer to update the source.
137
+ Any update to the spec resolved assumptions, answered open questions, other changes is appended to `.memlog.md` as it happens. When a change overrides something that came from a source input, offer to update that source too, so upstream and the spec don't silently diverge.
124
138
 
125
139
  ## Frontmatter conventions
126
140
 
127
141
  - `companions:` array of `.md` files downstream MUST read alongside SPEC.md to have the full contract. Paths may point inside the spec folder (spec-authored companions like `glossary.md`) or outside it (adopted companions like `../planning-artifacts/ux-designs/ux-foo-bar-2026-05-23/DESIGN.md`). The split between spec-authored and adopted is implicit by path; downstream treats both the same.
128
142
  - `sources:` array of paths to files that were **fully absorbed** into the SPEC, with no remaining downstream value (e.g., a PRD whose every load-bearing claim is now in the kernel). Listed for audit and for bmad-spec to re-read on update. Downstream does NOT read these. Files that downstream still needs to read belong in `companions:`, not here.
129
- - **Do not list** decision logs, README files, organizational artifacts, or any operational record of how upstream skills produced their artifacts. Those are not source content; they are process metadata that downstream consumers don't need.
143
+ - **Do not list** the memlog, README files, organizational artifacts, or any operational record of how upstream skills produced their artifacts. Those are not source content; they are process metadata that downstream consumers don't need.
@@ -1,6 +1,6 @@
1
1
  # Headless JSON Response
2
2
 
3
- The default invocation is headless: input goes in, JSON comes out. The contract is intentionally tiny — return the outcome and the files touched. Anything else a caller needs is inside those files (SPEC.md, companions, `.decision-log.md`).
3
+ The default invocation is headless: input goes in, JSON comes out. The contract is intentionally tiny — return the outcome and the files touched. Anything else a caller needs is inside those files (SPEC.md, companions, `.memlog.md`).
4
4
 
5
5
  ## Success
6
6
 
@@ -10,12 +10,12 @@ The default invocation is headless: input goes in, JSON comes out. The contract
10
10
  "files": [
11
11
  "_bmad-output/specs/spec-quarter-drop/SPEC.md",
12
12
  "_bmad-output/specs/spec-quarter-drop/glossary.md",
13
- "_bmad-output/specs/spec-quarter-drop/.decision-log.md"
13
+ "_bmad-output/specs/spec-quarter-drop/.memlog.md"
14
14
  ]
15
15
  }
16
16
  ```
17
17
 
18
- `files` lists every file written or modified in this run, in any order. The spec folder, kernel filename, decision log location, capabilities, companions, and verdict are all readable from those files; no need to re-encode them in the response.
18
+ `files` lists every file written or modified in this run, in any order. The spec folder, kernel filename, memlog location, capabilities, companions, and verdict are all readable from those files; no need to re-encode them in the response.
19
19
 
20
20
  ## Blocked
21
21
 
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  id: SPEC-{slug}
3
3
  companions: [] # files downstream MUST read alongside SPEC.md. Paths may point inside the spec folder (spec-authored) or outside it (adopted from an upstream skill).
4
- sources: [] # files fully absorbed into the SPEC (audit only; downstream does NOT read these). Never decision logs.
4
+ sources: [] # files fully absorbed into the SPEC (audit only; downstream does NOT read these). Never the memlog.
5
5
  ---
6
6
 
7
7
  > **Canonical contract.** This SPEC and the files in `companions:` are the complete, preservation-validated contract for what to build, test, and validate. Source documents listed in frontmatter are for traceability only — consult them only if you need narrative rationale or prose color this contract intentionally omits.
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env python3
2
+ # /// script
3
+ # requires-python = ">=3.8"
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
+ Three 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, edited, or removed. There is no
22
+ edit or delete subcommand by design; history is never rewritten.
23
+ 2. Write-only / blind. Every command is an atomic, context-free write and echoes the
24
+ new state as one line of JSON, so the caller never re-reads the file mid-session.
25
+ The one time the file is read is on resume — and the caller reads it itself, not
26
+ via this script.
27
+ 3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done,
28
+ blocked, or paused is itself a fact that happened, so it is recorded as an entry
29
+ (e.g. `append --type event --text "session complete"`), never as frontmatter the
30
+ log would have to mutate. The chronology stays the single source of truth, and a
31
+ resume learns the state by reading the last entries — the same way it learns
32
+ everything else.
33
+
34
+ Atomicity: every write goes to a temp file, is flushed and fsync'd, then atomically
35
+ renamed over the target, so a crash never leaves a half-written entry.
36
+
37
+ The file shape (.memlog.md):
38
+
39
+ ---
40
+ topic: Onboarding flow for a budgeting app
41
+ goal: lift week-1 retention
42
+ updated: 2026-06-07T14:22
43
+ ---
44
+
45
+ - (note) user picked techniques: SCAMPER, then Six Thinking Hats
46
+ - (technique) started SCAMPER
47
+ - (idea) skip the signup wall: let people try with sample data first
48
+ - (idea) auto-import one bank account so the first screen shows real numbers
49
+ - (question) is open-banking consent too heavy for step one?
50
+ - (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
51
+ - (direction) optimize for the anxious first-timer, not the power user
52
+ - (decision) lead with one pre-categorized account; defer multi-account import
53
+ - (event) session complete
54
+
55
+ Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
56
+ decision, direction, assumption, gap, note, event, …) — and an optional `--by` naming
57
+ who it came from (e.g. `user`, `coach`), for sessions where authorship matters. Both
58
+ render into one short inline tag: `(idea)`, `(idea by user)`, `(by coach)`. Omit them
59
+ for a plain note. The host skill names the vocabulary; the script does not enforce one.
60
+
61
+ Commands:
62
+ init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists)
63
+ append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end
64
+ set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field
65
+
66
+ Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md.
67
+ `--path` points straight at the memlog file instead, for callers that already hold the path.
68
+ """
69
+ from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+
70
+
71
+ import argparse
72
+ import json
73
+ import os
74
+ import sys
75
+ from datetime import datetime
76
+ from pathlib import Path
77
+
78
+ MEMLOG = ".memlog.md"
79
+
80
+
81
+ def now() -> str:
82
+ return datetime.now().strftime("%Y-%m-%dT%H:%M")
83
+
84
+
85
+ def resolve(args) -> Path:
86
+ """The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path."""
87
+ return Path(args.path) if args.path else Path(args.workspace) / MEMLOG
88
+
89
+
90
+ def split(text: str) -> tuple[dict, str]:
91
+ """Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
92
+
93
+ The closing fence is the first line that is *exactly* `---`, so a `---` inside a
94
+ field value (topic/goal are free user text) never truncates the frontmatter.
95
+ """
96
+ lines = text.splitlines()
97
+ if not lines or lines[0] != "---":
98
+ raise ValueError(".memlog.md has no frontmatter")
99
+ end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
100
+ if end is None:
101
+ raise ValueError(".memlog.md frontmatter is not terminated")
102
+ meta: dict[str, str] = {}
103
+ for line in lines[1:end]:
104
+ if ":" in line:
105
+ k, v = line.split(":", 1)
106
+ meta[k.strip()] = v.strip()
107
+ return meta, "\n".join(lines[end + 1:]).lstrip("\n")
108
+
109
+
110
+ def render(meta: dict, body: str) -> str:
111
+ # Neutralize newlines in values so a multi-line field can't break the fence on re-read.
112
+ fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
113
+ return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"
114
+
115
+
116
+ def touch(meta: dict) -> None:
117
+ """Stamp `updated` and keep it last so the field order stays predictable."""
118
+ meta.pop("updated", None)
119
+ meta["updated"] = now()
120
+
121
+
122
+ def write_atomic(path: Path, text: str) -> None:
123
+ """Temp + flush + fsync + atomic rename, so a crash never half-writes an entry."""
124
+ tmp = path.with_suffix(path.suffix + ".tmp")
125
+ with open(tmp, "w", encoding="utf-8") as f:
126
+ f.write(text)
127
+ f.flush()
128
+ os.fsync(f.fileno())
129
+ os.replace(tmp, path)
130
+
131
+
132
+ def entry_count(body: str) -> int:
133
+ return sum(1 for ln in body.splitlines() if ln.startswith("- "))
134
+
135
+
136
+ def ack(path: Path, body: str) -> None:
137
+ """Echo new state so the caller never re-reads the file to know where it stands."""
138
+ print(json.dumps({
139
+ "ok": True,
140
+ "memlog": str(path),
141
+ "entries": entry_count(body),
142
+ }))
143
+
144
+
145
+ def cmd_init(args) -> int:
146
+ path = resolve(args)
147
+ if path.exists():
148
+ print(f"error: {path} already exists; use append/set to update it", file=sys.stderr)
149
+ return 2
150
+ path.parent.mkdir(parents=True, exist_ok=True)
151
+ meta: dict[str, str] = {}
152
+ for pair in args.field or []:
153
+ if "=" not in pair:
154
+ print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr)
155
+ return 2
156
+ k, v = pair.split("=", 1)
157
+ meta[k.strip()] = v.strip()
158
+ touch(meta)
159
+ write_atomic(path, render(meta, ""))
160
+ ack(path, "")
161
+ return 0
162
+
163
+
164
+ def cmd_append(args) -> int:
165
+ path = resolve(args)
166
+ meta, body = split(path.read_text(encoding="utf-8"))
167
+ text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat
168
+ label = args.type or ""
169
+ if args.by:
170
+ label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)"
171
+ tag = f"({label}) " if label else ""
172
+ entry = f"- {tag}{text}"
173
+ body = (body.rstrip("\n") + "\n" + entry) if body.strip() else entry # always at the end
174
+ touch(meta)
175
+ write_atomic(path, render(meta, body))
176
+ ack(path, body)
177
+ return 0
178
+
179
+
180
+ def cmd_set(args) -> int:
181
+ path = resolve(args)
182
+ meta, body = split(path.read_text(encoding="utf-8"))
183
+ meta[args.key] = args.value
184
+ touch(meta)
185
+ write_atomic(path, render(meta, body))
186
+ ack(path, body)
187
+ return 0
188
+
189
+
190
+ def add_target(sp) -> None:
191
+ """Every command addresses the memlog the same way: a run folder or an explicit path."""
192
+ g = sp.add_mutually_exclusive_group(required=True)
193
+ g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md")
194
+ g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)")
195
+
196
+
197
+ def main(argv: list[str] | None = None) -> int:
198
+ p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
199
+ sub = p.add_subparsers(dest="cmd", required=True)
200
+
201
+ pi = sub.add_parser("init", help="create the memlog")
202
+ add_target(pi)
203
+ pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)")
204
+ pi.set_defaults(func=cmd_init)
205
+
206
+ pa = sub.add_parser("append", help="append one entry at the end")
207
+ add_target(pa)
208
+ pa.add_argument("--text", required=True)
209
+ pa.add_argument("--type", help="entry kind, rendered as an inline tag")
210
+ pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag")
211
+ pa.set_defaults(func=cmd_append)
212
+
213
+ pset = sub.add_parser("set", help="set a descriptive frontmatter field")
214
+ add_target(pset)
215
+ pset.add_argument("--key", required=True)
216
+ pset.add_argument("--value", required=True)
217
+ pset.set_defaults(func=cmd_set)
218
+
219
+ args = p.parse_args(argv)
220
+ return args.func(args)
221
+
222
+
223
+ if __name__ == "__main__":
224
+ sys.exit(main())
@@ -0,0 +1,306 @@
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, and no
9
+ lifecycle status the log would have to mutate.
10
+ """
11
+ import json
12
+ import sys
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
18
+ import memlog # noqa: E402
19
+
20
+ MEMLOG = ".memlog.md"
21
+
22
+
23
+ @pytest.fixture
24
+ def ws(tmp_path):
25
+ return str(tmp_path)
26
+
27
+
28
+ def read(ws):
29
+ return (Path(ws) / MEMLOG).read_text(encoding="utf-8")
30
+
31
+
32
+ def body_of(ws):
33
+ return memlog.split(read(ws))[1]
34
+
35
+
36
+ def entries(ws):
37
+ return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")]
38
+
39
+
40
+ def init(ws, **fields):
41
+ fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"}
42
+ argv = ["init", "--workspace", ws]
43
+ for k, v in fields.items():
44
+ argv += ["--field", f"{k}={v}"]
45
+ assert memlog.main(argv) == 0
46
+
47
+
48
+ def append(ws, text, entry_type=None, by=None):
49
+ argv = ["append", "--workspace", ws, "--text", text]
50
+ if entry_type:
51
+ argv += ["--type", entry_type]
52
+ if by:
53
+ argv += ["--by", by]
54
+ assert memlog.main(argv) == 0
55
+
56
+
57
+ # --- init ---------------------------------------------------------------
58
+
59
+ def test_init_writes_frontmatter_fields(ws):
60
+ init(ws)
61
+ meta, body = memlog.split(read(ws))
62
+ assert meta["topic"] == "Reinvent the lunchbox"
63
+ assert meta["goal"] == "ideas for a pitch"
64
+ assert "updated" in meta
65
+ assert body.strip() == ""
66
+
67
+
68
+ def test_init_has_no_lifecycle_status(ws):
69
+ # A memory log carries no "status" flag; completion is an appended entry, not frontmatter.
70
+ init(ws)
71
+ meta, _ = memlog.split(read(ws))
72
+ assert "status" not in meta
73
+
74
+
75
+ def test_init_arbitrary_fields(ws):
76
+ init(ws, topic="T", audience="board")
77
+ meta, _ = memlog.split(read(ws))
78
+ assert meta["audience"] == "board"
79
+
80
+
81
+ def test_init_refuses_overwrite(ws):
82
+ init(ws)
83
+ assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2
84
+
85
+
86
+ def test_init_creates_missing_workspace(tmp_path):
87
+ nested = str(tmp_path / "a" / "b")
88
+ assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0
89
+ assert (Path(nested) / MEMLOG).is_file()
90
+
91
+
92
+ def test_init_rejects_malformed_field(ws):
93
+ assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2
94
+
95
+
96
+ # --- addressing: --workspace and --path are interchangeable --------------
97
+
98
+ def test_path_addressing_targets_the_file_directly(tmp_path):
99
+ target = tmp_path / "run" / ".memlog.md"
100
+ assert memlog.main(["init", "--path", str(target), "--field", "topic=T"]) == 0
101
+ assert target.is_file()
102
+ assert memlog.main(["append", "--path", str(target), "--text", "an idea", "--type", "idea"]) == 0
103
+ body = memlog.split(target.read_text(encoding="utf-8"))[1]
104
+ assert "- (idea) an idea" in body
105
+
106
+
107
+ def test_workspace_and_path_resolve_to_same_file(ws):
108
+ init(ws)
109
+ via_path = str(Path(ws) / MEMLOG)
110
+ assert memlog.main(["append", "--path", via_path, "--text", "from path"]) == 0
111
+ assert memlog.main(["append", "--workspace", ws, "--text", "from workspace"]) == 0
112
+ assert entries(ws) == ["- from path", "- from workspace"]
113
+
114
+
115
+ def test_target_is_required(ws):
116
+ with pytest.raises(SystemExit):
117
+ memlog.main(["append", "--text", "orphan"]) # neither --workspace nor --path
118
+
119
+
120
+ # --- append: flat chronological order is the whole point -----------------
121
+
122
+ def test_append_lands_at_end_in_order(ws):
123
+ init(ws)
124
+ append(ws, "first")
125
+ append(ws, "second")
126
+ append(ws, "third")
127
+ assert entries(ws) == ["- first", "- second", "- third"]
128
+
129
+
130
+ def test_no_sections_or_headings_ever(ws):
131
+ init(ws)
132
+ append(ws, "started foo", entry_type="technique")
133
+ append(ws, "an idea", entry_type="idea")
134
+ append(ws, "started bar", entry_type="technique")
135
+ assert "## " not in body_of(ws) # the flat log never grows headings
136
+
137
+
138
+ def test_type_renders_as_inline_tag(ws):
139
+ init(ws)
140
+ append(ws, "the earth revolves around the sun", entry_type="idea")
141
+ append(ws, "how do we handle stampede?", entry_type="question")
142
+ body = body_of(ws)
143
+ assert "- (idea) the earth revolves around the sun" in body
144
+ assert "- (question) how do we handle stampede?" in body
145
+
146
+
147
+ def test_append_without_type_is_plain_note(ws):
148
+ init(ws)
149
+ append(ws, "bare entry")
150
+ assert entries(ws) == ["- bare entry"]
151
+
152
+
153
+ def test_completion_is_an_entry_not_a_status(ws):
154
+ # The documented way to mark a session done: append it. Frontmatter never gains a status.
155
+ init(ws)
156
+ append(ws, "session complete", entry_type="event")
157
+ meta, _ = memlog.split(read(ws))
158
+ assert "status" not in meta
159
+ assert entries(ws)[-1] == "- (event) session complete"
160
+
161
+
162
+ def test_append_collapses_newlines_into_one_line(ws):
163
+ init(ws)
164
+ append(ws, "line one\nline two\n spaced out")
165
+ assert entries(ws) == ["- line one line two spaced out"]
166
+
167
+
168
+ def test_revisited_technique_is_just_a_later_entry(ws):
169
+ # the user's model: switching techniques is an entry, not a section to return to
170
+ init(ws)
171
+ append(ws, "started SCAMPER", entry_type="technique")
172
+ append(ws, "magnetic latch", entry_type="idea")
173
+ append(ws, "started Six Hats", entry_type="technique")
174
+ append(ws, "stale data risk", entry_type="idea")
175
+ append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again
176
+ append(ws, "stackable tiers", entry_type="idea")
177
+ assert entries(ws) == [
178
+ "- (technique) started SCAMPER",
179
+ "- (idea) magnetic latch",
180
+ "- (technique) started Six Hats",
181
+ "- (idea) stale data risk",
182
+ "- (technique) started SCAMPER",
183
+ "- (idea) stackable tiers",
184
+ ]
185
+
186
+
187
+ def test_by_renders_attribution_in_tag(ws):
188
+ # Creative Partner mode must record whose idea each one was
189
+ init(ws)
190
+ append(ws, "magnetic latch lid", entry_type="idea", by="user")
191
+ append(ws, "lid doubles as a plate", entry_type="idea", by="coach")
192
+ body = body_of(ws)
193
+ assert "- (idea by user) magnetic latch lid" in body
194
+ assert "- (idea by coach) lid doubles as a plate" in body
195
+
196
+
197
+ def test_by_without_type_renders_alone(ws):
198
+ init(ws)
199
+ append(ws, "off-the-cuff thought", by="coach")
200
+ assert entries(ws) == ["- (by coach) off-the-cuff thought"]
201
+
202
+
203
+ def test_heterogeneous_entry_types_coexist(ws):
204
+ init(ws)
205
+ append(ws, "an idea", entry_type="idea")
206
+ append(ws, "an open question", entry_type="question")
207
+ append(ws, "a decision we made", entry_type="decision")
208
+ append(ws, "user wants mobile-first", entry_type="direction")
209
+ body = body_of(ws)
210
+ for tag in ("(idea)", "(question)", "(decision)", "(direction)"):
211
+ assert tag in body
212
+
213
+
214
+ def test_free_vocabulary_is_not_enforced(ws):
215
+ # The tool is neutral: any --type the host skill names renders verbatim.
216
+ init(ws)
217
+ append(ws, "a custom kind", entry_type="crack")
218
+ append(ws, "another", entry_type="lock")
219
+ body = body_of(ws)
220
+ assert "- (crack) a custom kind" in body
221
+ assert "- (lock) another" in body
222
+
223
+
224
+ # --- set: generic descriptive frontmatter, no lifecycle semantics --------
225
+
226
+ def test_set_adds_field(ws):
227
+ init(ws)
228
+ memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
229
+ assert memlog.split(read(ws))[0]["mode"] == "partner"
230
+
231
+
232
+ def test_set_replaces_field(ws):
233
+ init(ws, topic="T", mode="facilitator")
234
+ memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
235
+ assert memlog.split(read(ws))[0]["mode"] == "partner"
236
+
237
+
238
+ def test_set_preserves_body(ws):
239
+ init(ws)
240
+ append(ws, "keep me", entry_type="idea")
241
+ memlog.main(["set", "--workspace", ws, "--key", "mode", "--value", "partner"])
242
+ meta, body = memlog.split(read(ws))
243
+ assert meta["mode"] == "partner"
244
+ assert "- (idea) keep me" in body
245
+
246
+
247
+ def test_updated_stays_last(ws):
248
+ init(ws)
249
+ memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"])
250
+ meta = memlog.split(read(ws))[0]
251
+ assert list(meta)[-1] == "updated"
252
+
253
+
254
+ # --- robustness ---------------------------------------------------------
255
+
256
+ def test_roundtrip_render_is_stable(ws):
257
+ init(ws)
258
+ append(ws, "one", entry_type="idea")
259
+ first = read(ws)
260
+ meta, body = memlog.split(first)
261
+ assert memlog.render(meta, body) == first
262
+
263
+
264
+ def test_commas_in_field_survive(ws):
265
+ init(ws, topic="cars, trains, and planes")
266
+ append(ws, "z", entry_type="idea")
267
+ meta, _ = memlog.split(read(ws))
268
+ assert meta["topic"] == "cars, trains, and planes"
269
+
270
+
271
+ def test_triple_dash_in_field_does_not_corrupt_frontmatter(ws):
272
+ # A `---` inside a value must NOT be read as the closing fence: topic stays intact
273
+ # and the body never leaks frontmatter text.
274
+ init(ws, topic="Pricing --- tiers --- and add-ons")
275
+ append(ws, "an idea", entry_type="idea")
276
+ meta, body = memlog.split(read(ws))
277
+ assert meta["topic"] == "Pricing --- tiers --- and add-ons"
278
+ assert entries(ws) == ["- (idea) an idea"]
279
+ assert "topic:" not in body # frontmatter never bled into the body
280
+
281
+
282
+ def test_newline_in_field_is_neutralized(ws):
283
+ # A value carrying a newline can't break the fence on the next round-trip.
284
+ memlog.main(["init", "--workspace", ws, "--field", "topic=line one\nline two"])
285
+ append(ws, "x", entry_type="idea")
286
+ meta, _ = memlog.split(read(ws))
287
+ assert "\n" not in meta["topic"]
288
+
289
+
290
+ def test_append_emits_json_ack(ws, capsys):
291
+ init(ws)
292
+ append(ws, "x", entry_type="idea")
293
+ out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
294
+ assert out["ok"] is True
295
+ assert out["entries"] == 1
296
+ assert out["memlog"].endswith(MEMLOG)
297
+ assert "status" not in out # no lifecycle status
298
+ assert "section" not in out # sections are gone
299
+
300
+
301
+ def test_ack_entry_count_climbs(ws, capsys):
302
+ init(ws)
303
+ append(ws, "a")
304
+ append(ws, "b")
305
+ out = json.loads(capsys.readouterr().out.strip().splitlines()[-1])
306
+ assert out["entries"] == 2
@@ -419,10 +419,35 @@ class Installer {
419
419
  const sourceDir = path.dirname(path.join(bmadDir, relativePath));
420
420
  if (await fs.pathExists(sourceDir)) {
421
421
  await fs.remove(sourceDir);
422
+ await this._removeEmptyParents(path.dirname(sourceDir), bmadDir);
422
423
  }
423
424
  }
424
425
  }
425
426
 
427
+ /**
428
+ * Remove now-empty parent directories left behind after skill dir cleanup.
429
+ * Walks up from dir, stopping at (and never removing) bmadDir. Best-effort:
430
+ * a directory that vanishes or fills in mid-walk just ends the walk.
431
+ * @param {string} dir - Directory to start walking up from
432
+ * @param {string} bmadDir - BMAD installation directory (boundary)
433
+ */
434
+ async _removeEmptyParents(dir, bmadDir) {
435
+ let current = dir;
436
+ while (true) {
437
+ // Path-boundary check (not a string prefix, so siblings like _bmad2 don't match).
438
+ const rel = path.relative(bmadDir, current);
439
+ if (rel === '' || rel.startsWith('..') || path.isAbsolute(rel)) break;
440
+ try {
441
+ const entries = await fs.readdir(current);
442
+ if (entries.length > 0) break;
443
+ await fs.rmdir(current);
444
+ } catch {
445
+ break;
446
+ }
447
+ current = path.dirname(current);
448
+ }
449
+ }
450
+
426
451
  async _readSkillManifestRows(bmadDir) {
427
452
  const csvPath = path.join(bmadDir, '_config', 'skill-manifest.csv');
428
453
  if (!(await fs.pathExists(csvPath))) return [];
@@ -630,6 +655,7 @@ class Installer {
630
655
  /**
631
656
  * Sync src/scripts/* → _bmad/scripts/ so shared Python scripts
632
657
  * (e.g. resolve_customization.py) are available at install time.
658
+ * Excludes dev-only tests and Python caches so they don't ship to users.
633
659
  * Wipes the destination first so files removed or renamed in source
634
660
  * don't linger and get recorded as installed. Also seeds
635
661
  * _bmad/custom/.gitignore on fresh installs so *.user.toml overrides
@@ -643,7 +669,12 @@ class Installer {
643
669
 
644
670
  await fs.remove(paths.scriptsDir);
645
671
  await fs.ensureDir(paths.scriptsDir);
646
- await fs.copy(srcScriptsDir, paths.scriptsDir, { overwrite: true });
672
+ // Ship only the runtime scripts — dev-only tests and Python caches must not land in user projects.
673
+ const isInstallable = (srcPath) => {
674
+ const base = path.basename(srcPath);
675
+ return base !== 'tests' && base !== '__pycache__' && base !== '.pytest_cache' && !base.endsWith('.pyc');
676
+ };
677
+ await fs.copy(srcScriptsDir, paths.scriptsDir, { overwrite: true, filter: isInstallable });
647
678
  await this._trackFilesRecursive(paths.scriptsDir);
648
679
 
649
680
  const customGitignore = path.join(paths.customDir, '.gitignore');