diff --git a/src/core-skills/bmad-forge-idea/SKILL.md b/src/core-skills/bmad-forge-idea/SKILL.md new file mode 100644 index 000000000..14f71b586 --- /dev/null +++ b/src/core-skills/bmad-forge-idea/SKILL.md @@ -0,0 +1,71 @@ +--- +name: bmad-forge-idea +description: Pressure-test an idea through persona-driven interrogation until it hardens, proves out, or dies cheaply. Use when the user says 'forge an idea', 'pressure-test this idea', 'stress-test my thinking', or 'harden this idea'. +--- + +# BMad Forge Idea + +## Overview + +Take a half-formed idea out of the user's head and pressure-test it now, in conversation, where changing your mind is free — until what survives is something they can act on with earned conviction, or it dies cheaply. The enemy is the hole you cannot see in your own idea: every unexamined assumption and unresolved branch is a crack that otherwise surfaces later, in the build or the launch, when it costs far more to fix. + +The product is the quality of the user's thinking, not an artifact. Hardening an idea, proving or disproving it, or just being an unsparing thinking partner are each a complete outcome. A refined brief and a handoff downstream are one optional exit, never the destination — so never herd the user toward "shall we build it?" + +This is domain-agnostic. The idea may be software, a business model, a creative concept, a research hypothesis, a life decision — anything. + +Act as an exacting interrogator who would rather find the crack than spare the feelings. This is interactive and socratic by nature; there is no headless mode. + +## Conventions + +- Bare paths (e.g. `scripts/memlog.py`) resolve from `{skill-root}`; `{project-root}`-prefixed paths from the project working directory. +- `{workflow.}` resolves to fields in the merged `customize.toml` `[workflow]` table. + +## On Activation + +1. Resolve customization: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure, read `{skill-root}/customize.toml` directly with defaults. Apply the resolved `{workflow.*}` values throughout. +2. Run each `{workflow.activation_steps_prepend}` entry; treat each `{workflow.persistent_facts}` entry as foundational context (`file:` entries load their contents, `skill:` names a skill to consult, others are facts verbatim). +3. Load `{project-root}/_bmad/core/config.yaml` (and `config.user.yaml` if present); resolve `{user_name}`, `{communication_language}`, `{output_folder}`, `{date}`. Missing → neutral defaults; never block. Greet `{user_name}` in `{communication_language}` and stay in it. +4. Note whether a BMad persona is already active in this conversation — the user loaded one (e.g. the analyst, the architect) and invoked the forge from within it. If so, that persona leads the session, in voice, throughout. +5. Resume: glob `{workflow.forge_output_path}/*/.memlog.md` and read only each match's frontmatter to find any whose `status` is not `complete`. Offer to resume one — then read its full memlog once to rebuild state and continue append-only — or to start fresh. + +Run each `{workflow.activation_steps_append}` entry. + +## Open the session + +Open cold. Acknowledging the idea is not endorsing it — do not praise it before it has survived anything, on this turn or any turn. The pull to validate the idea up front to build rapport is the exact reflex this skill exists to refuse. + +Determine the goal before you start pressing, unless a persona is already active and an idea is already on the table (then the goal is usually evident — confirm it in a line rather than asking). Otherwise ask, in one message: what is the idea, and what do you want from working it over — harden it, prove or kill it, or just think it through? The goal steers how the forge pushes: proving an idea goes for the load-bearing claim first; hardening one already committed to drives each branch to a resolved answer. + +Tell the user the two gears they can call at any moment: **"adversarial on this"** to have one idea attacked to destruction (you attack, they defend, "switch roles" flips), and **"bring in the room"** to have the relevant BMad experts work the idea with you. + +Derive a kebab-case `{slug}` for the idea and bind the session workspace `{workspace} = {workflow.forge_output_path}/{workflow.run_folder_pattern}` (the pattern fills with `{slug}`). Create the memlog once the goal is known: +`python3 {skill-root}/scripts/memlog.py init --workspace {workspace} --field idea="" --field goal=""` +Tell the user the path; state is on disk now, so the session survives interruption. + +## The forge + +Work the idea one question at a time — never a wall of questions, never a menu. Move through its dependent decisions in an order where each answer unlocks the next, and don't leave a decision half-settled to chase a shinier one. Put your own recommended answer on the table with each question; a position the user can push against gets further than an open prompt. When an answer is already discoverable — a file, a doc, a source they pointed you to — find it yourself rather than asking. When a branch resolves, say so and give the user a beat to add anything before you move on; the crack they were holding back tends to surface in that opening. + +Hold one stance against your defaults the whole way: **never default-agree.** Reflexive agreement lowers the pressure and the user thinks shallower for it. Attack the weak point or build on the strong one — whichever drives deeper thinking — and praise only what genuinely earns it. The objective is the best idea, not a comfortable user. + +Capture as you go — every resolved decision, surfaced assumption, crack found, and branch killed, one line each in the user's meaning: +`python3 {skill-root}/scripts/memlog.py append --workspace {workspace} --type --text ""` +Do not read the memlog back mid-session; resume is the one exception. If the user raises something that belongs to a different branch, capture it and stay where you are rather than chasing it — the loop and the stray insight both survive. + +## The personas + +The forge is voiced, not generic. A persona loaded at activation leads it and holds character. Absent one, you may adopt the roster persona that best fits the idea, or press it straight — but do not flatten into a faceless assistant when the room holds someone sharper for the job. + +When the user brings in the room, the personas are recruited witnesses you cross-examine, not a panel that debates. Resolve the installed roster: +`python3 {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key agents` +For the branch in front of you, pull in the one or two personas whose expertise finds its specific cracks — the architect on a scaling decision, the strategist on the business model, the analyst on an unvalidated assumption. They hammer that branch in character; you synthesize their hits into the next question and drive it to a resolved answer that lands in the memlog. The lead stays primary; others lean in for a beat and step back. Voice these personas yourself by default; when a branch genuinely needs independent minds — a verdict that shouldn't be colored by one voice speaking for all — spawn them as separate agents the way `bmad-party-mode` does. If the roster cannot be resolved, voice the fitting lens yourself rather than dropping the gear. + +## Exits + +The session ends however the thinking lands, and every landing is a real outcome: + +- **Hardened** — the idea survived. Offer to distill the memlog into a refined-idea brief at `{workspace}/brief.md` — built from the resolved decisions and killed branches, the load-bearing part downstream actually consumes, not a prose retelling — and note it can feed `bmad-spec` or `bmad-quick-dev` (both consume any intent) if the user wants to go further. Offer; do not herd. +- **Killed** — the idea did not survive. Say so plainly and record why. Finding this cheaply is a win, not a failure. +- **Clearer** — the user simply thinks straighter now. The memlog stands on its own; no artifact needed. + +Flip the status at the end: `python3 {skill-root}/scripts/memlog.py set --workspace {workspace} --key status --value complete`. If `{workflow.on_complete}` is non-empty, run it (a scalar is one instruction, an array runs in order). diff --git a/src/core-skills/bmad-forge-idea/customize.toml b/src/core-skills/bmad-forge-idea/customize.toml new file mode 100644 index 000000000..aa470efb9 --- /dev/null +++ b/src/core-skills/bmad-forge-idea/customize.toml @@ -0,0 +1,41 @@ +# DO NOT EDIT -- overwritten on every update. +# +# Workflow customization surface for bmad-forge-idea. +# +# Override files (not edited here): +# {project-root}/_bmad/custom/bmad-forge-idea.toml (team) +# {project-root}/_bmad/custom/bmad-forge-idea.user.toml (personal) + +[workflow] + +# --- Configurable below. Overrides merge per BMad structural rules: --- +# scalars: override wins • arrays: append + +# Steps to run before the standard activation (config load, greet). +activation_steps_prepend = [] + +# Steps to run after greet but before the session begins. +activation_steps_append = [] + +# Persistent facts the interrogator keeps in mind for the whole session +# (domain constraints, house rules, what's off the table). Each entry is a +# literal sentence, a skill prefixed with `skill:`, or a `file:`-prefixed +# path/glob whose contents are loaded as facts. Default loads a project-context +# file when one exists, so the forge knows the domain without re-asking. +persistent_facts = [ + "file:{project-root}/**/project-context.md", +] + +# Executed when the session completes. Scalar or array of instructions. Empty for none. +on_complete = "" + +# Session output location. The memlog and any distilled brief land inside +# `{forge_output_path}/{run_folder_pattern}/`. The resume check globs +# `{forge_output_path}/*/.memlog.md`. Lands directly under {output_folder} so the +# skill works in core-only installs. +forge_output_path = "{output_folder}/forge" + +# Run-folder pattern inside forge_output_path. Resolved against the idea-derived +# slug at session start. Same slug = same folder, so returning to an idea resumes +# it in place. Override to add {date} if a fresh dated history is preferred. +run_folder_pattern = "{slug}" diff --git a/src/core-skills/bmad-forge-idea/scripts/memlog.py b/src/core-skills/bmad-forge-idea/scripts/memlog.py new file mode 100644 index 000000000..99ec13225 --- /dev/null +++ b/src/core-skills/bmad-forge-idea/scripts/memlog.py @@ -0,0 +1,202 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.10" +# /// +"""memlog — an append-only memory log: LLM-optimal working memory for a skill. + +A memlog is the dense, chronological record of everything that mattered in a piece of +work — every item the user generated or accepted — kept minimal like human memory: only +what's important, never bloated. It persists ACROSS sessions, so a fresh session can +load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a +deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary +by how it calls `append` — the tool stays neutral. + +It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded +at the END in the order it happened. The chronology itself is the structure — an event +like "started technique X" is just another entry, same as an idea or an insight. + +Two invariants make it trustworthy: + + 1. Append-only, chronological. Entries land at the end, in the order they happen. + Nothing is ever inserted backward, reordered, or grouped. + 2. Write-only / blind. Every command is an atomic, context-free write and echoes the + new state as JSON, so the caller never re-reads the file mid-session. The one time + the file is read is on resume — and the caller reads it itself, not via this script. + +The file shape (.memlog.md): + + --- + topic: Onboarding flow for a budgeting app + goal: lift week-1 retention + status: active + updated: 2026-05-30T14:22 + --- + + - (note) user picked techniques: SCAMPER, then Six Thinking Hats + - (technique) started SCAMPER + - (idea) skip the signup wall: let people try with sample data first + - (idea) auto-import one bank account so the first screen shows real numbers + - (question) is open-banking consent too heavy for step one? + - (technique) started Six Thinking Hats + - (idea) black-hat: imported transactions look scary before they're categorized + - (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized + - (direction) user wants to optimize for the anxious first-timer, not the power user + - (decision) lead with one pre-categorized account; defer multi-account import + +Each entry may carry an optional `--type` — what KIND it is (idea, insight, question, +decision, technique, …) — and an optional `--by` naming who it came from (e.g. `user`, +`coach`), for sessions where authorship matters. Both render into one short inline tag: +`(idea)`, `(idea by user)`, `(by coach)`. Omit them for a plain note. The host skill +names the vocabulary; the script does not. + +Commands: + init --workspace DIR [--field k=v ...] create the memlog (errors if it exists) + append --workspace DIR --text STR [--type T] [--by W] append one entry at the end + set --workspace DIR --key K --value V set/replace a frontmatter field + +The workspace is the run folder; the memlog is always {workspace}/.memlog.md. +""" +import argparse +import json +import os +import sys +from datetime import datetime +from pathlib import Path + +MEMLOG = ".memlog.md" + + +def now() -> str: + return datetime.now().strftime("%Y-%m-%dT%H:%M") + + +def memlog_path(workspace: str) -> Path: + return Path(workspace) / MEMLOG + + +def split(text: str) -> tuple[dict, str]: + """Return (frontmatter dict in source order, body str). Frontmatter is plain key: value. + + 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") + 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] = {} + for line in lines[1:end]: + if ":" in line: + k, v = line.split(":", 1) + meta[k.strip()] = v.strip() + return meta, "\n".join(lines[end + 1:]).lstrip("\n") + + +def render(meta: dict, body: str) -> str: + # 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" + + +def touch(meta: dict) -> None: + """Stamp `updated` and keep it last so the field order stays predictable.""" + meta.pop("updated", None) + meta["updated"] = now() + + +def write_atomic(path: Path, text: str) -> None: + tmp = path.with_suffix(path.suffix + ".tmp") + tmp.write_text(text, encoding="utf-8") + os.replace(tmp, path) + + +def entry_count(body: str) -> int: + return sum(1 for ln in body.splitlines() if ln.startswith("- ")) + + +def ack(path: Path, meta: dict, body: str) -> None: + """Echo new state so the caller never re-reads the file to know where it stands.""" + print(json.dumps({ + "ok": True, + "memlog": str(path), + "status": meta.get("status", ""), + "entries": entry_count(body), + })) + + +def cmd_init(args) -> int: + path = memlog_path(args.workspace) + if path.exists(): + print(f"error: {path} already exists; use append/set to update it", file=sys.stderr) + return 2 + path.parent.mkdir(parents=True, exist_ok=True) + meta: dict[str, str] = {} + for pair in args.field or []: + if "=" not in pair: + print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr) + return 2 + k, v = pair.split("=", 1) + meta[k.strip()] = v.strip() + meta.setdefault("status", "active") + touch(meta) + write_atomic(path, render(meta, "")) + ack(path, meta, "") + return 0 + + +def cmd_append(args) -> int: + path = memlog_path(args.workspace) + meta, body = split(path.read_text(encoding="utf-8")) + text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat + label = args.type or "" + if args.by: + label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)" + tag = f"({label}) " if label else "" + entry = f"- {tag}{text}" + body = (body.rstrip("\n") + "\n" + entry) if body.strip() else entry # always at the end + touch(meta) + write_atomic(path, render(meta, body)) + ack(path, meta, body) + return 0 + + +def cmd_set(args) -> int: + path = memlog_path(args.workspace) + meta, body = split(path.read_text(encoding="utf-8")) + meta[args.key] = args.value + touch(meta) + write_atomic(path, render(meta, body)) + ack(path, meta, body) + return 0 + + +def main(argv: list[str] | None = None) -> int: + p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + sub = p.add_subparsers(dest="cmd", required=True) + + pi = sub.add_parser("init", help="create the memlog") + pi.add_argument("--workspace", required=True) + pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)") + pi.set_defaults(func=cmd_init) + + pa = sub.add_parser("append", help="append one entry at the end") + pa.add_argument("--workspace", required=True) + pa.add_argument("--text", required=True) + pa.add_argument("--type", help="entry kind, rendered as an inline tag") + pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag") + pa.set_defaults(func=cmd_append) + + pset = sub.add_parser("set", help="set a frontmatter field") + pset.add_argument("--workspace", required=True) + pset.add_argument("--key", required=True) + pset.add_argument("--value", required=True) + pset.set_defaults(func=cmd_set) + + args = p.parse_args(argv) + return args.func(args) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/src/core-skills/bmad-forge-idea/scripts/tests/test_memlog.py b/src/core-skills/bmad-forge-idea/scripts/tests/test_memlog.py new file mode 100644 index 000000000..5e7813829 --- /dev/null +++ b/src/core-skills/bmad-forge-idea/scripts/tests/test_memlog.py @@ -0,0 +1,265 @@ +# /// script +# requires-python = ">=3.10" +# dependencies = ["pytest>=8.0"] +# /// +"""Tests for memlog.py. Run: uv run --with pytest pytest scripts/tests/test_memlog.py + +The spine under test is the flat, append-only, chronological invariant: every entry is +one line recorded at the end in the order it happened — no sections, no grouping. +""" +import json +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) +import memlog # noqa: E402 + +MEMLOG = ".memlog.md" + + +@pytest.fixture +def ws(tmp_path): + return str(tmp_path) + + +def read(ws): + return (Path(ws) / MEMLOG).read_text(encoding="utf-8") + + +def body_of(ws): + return memlog.split(read(ws))[1] + + +def entries(ws): + return [ln for ln in body_of(ws).splitlines() if ln.startswith("- ")] + + +def init(ws, **fields): + fields = fields or {"topic": "Reinvent the lunchbox", "goal": "ideas for a pitch"} + argv = ["init", "--workspace", ws] + for k, v in fields.items(): + argv += ["--field", f"{k}={v}"] + assert memlog.main(argv) == 0 + + +def append(ws, text, entry_type=None, by=None): + argv = ["append", "--workspace", ws, "--text", text] + if entry_type: + argv += ["--type", entry_type] + if by: + argv += ["--by", by] + assert memlog.main(argv) == 0 + + +# --- init --------------------------------------------------------------- + +def test_init_writes_frontmatter_fields(ws): + init(ws) + meta, body = memlog.split(read(ws)) + assert meta["topic"] == "Reinvent the lunchbox" + assert meta["goal"] == "ideas for a pitch" + assert meta["status"] == "active" + assert "updated" in meta + assert body.strip() == "" + + +def test_init_arbitrary_fields(ws): + init(ws, topic="T", audience="board") + meta, _ = memlog.split(read(ws)) + assert meta["audience"] == "board" + + +def test_init_refuses_overwrite(ws): + init(ws) + assert memlog.main(["init", "--workspace", ws, "--field", "topic=other"]) == 2 + + +def test_init_creates_missing_workspace(tmp_path): + nested = str(tmp_path / "a" / "b") + assert memlog.main(["init", "--workspace", nested, "--field", "topic=T"]) == 0 + assert (Path(nested) / MEMLOG).is_file() + + +def test_init_rejects_malformed_field(ws): + assert memlog.main(["init", "--workspace", ws, "--field", "noequals"]) == 2 + + +# --- append: flat chronological order is the whole point ----------------- + +def test_append_lands_at_end_in_order(ws): + init(ws) + append(ws, "first") + append(ws, "second") + append(ws, "third") + assert entries(ws) == ["- first", "- second", "- third"] + + +def test_no_sections_or_headings_ever(ws): + init(ws) + append(ws, "started foo", entry_type="technique") + append(ws, "an idea", entry_type="idea") + append(ws, "started bar", entry_type="technique") + assert "## " not in body_of(ws) # the flat log never grows headings + + +def test_type_renders_as_inline_tag(ws): + init(ws) + append(ws, "the earth revolves around the sun", entry_type="idea") + append(ws, "how do we handle stampede?", entry_type="question") + body = body_of(ws) + assert "- (idea) the earth revolves around the sun" in body + assert "- (question) how do we handle stampede?" in body + + +def test_append_without_type_is_plain_note(ws): + init(ws) + append(ws, "bare entry") + assert entries(ws) == ["- bare entry"] + + +def test_append_collapses_newlines_into_one_line(ws): + init(ws) + append(ws, "line one\nline two\n spaced out") + assert entries(ws) == ["- line one line two spaced out"] + + +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 + init(ws) + append(ws, "started SCAMPER", entry_type="technique") + append(ws, "magnetic latch", entry_type="idea") + append(ws, "started Six Hats", entry_type="technique") + append(ws, "stale data risk", entry_type="idea") + append(ws, "started SCAMPER", entry_type="technique") # back to SCAMPER — just appended again + append(ws, "stackable tiers", entry_type="idea") + assert entries(ws) == [ + "- (technique) started SCAMPER", + "- (idea) magnetic latch", + "- (technique) started Six Hats", + "- (idea) stale data risk", + "- (technique) started SCAMPER", + "- (idea) stackable tiers", + ] + + +def test_by_renders_attribution_in_tag(ws): + # Creative Partner mode must record whose idea each one was + init(ws) + append(ws, "magnetic latch lid", entry_type="idea", by="user") + append(ws, "lid doubles as a plate", entry_type="idea", by="coach") + body = body_of(ws) + assert "- (idea by user) magnetic latch lid" in body + assert "- (idea by coach) lid doubles as a plate" in body + + +def test_by_without_type_renders_alone(ws): + init(ws) + append(ws, "off-the-cuff thought", by="coach") + assert entries(ws) == ["- (by coach) off-the-cuff thought"] + + +def test_heterogeneous_entry_types_coexist(ws): + init(ws) + append(ws, "an idea", entry_type="idea") + append(ws, "an open question", entry_type="question") + append(ws, "a decision we made", entry_type="decision") + append(ws, "user wants mobile-first", entry_type="direction") + body = body_of(ws) + for tag in ("(idea)", "(question)", "(decision)", "(direction)"): + assert tag in body + + +# --- set ---------------------------------------------------------------- + +def test_set_flips_status(ws): + init(ws) + memlog.main(["set", "--workspace", ws, "--key", "status", "--value", "complete"]) + assert memlog.split(read(ws))[0]["status"] == "complete" + + +def test_set_preserves_body(ws): + init(ws) + append(ws, "keep me", entry_type="idea") + memlog.main(["set", "--workspace", ws, "--key", "status", "--value", "complete"]) + meta, body = memlog.split(read(ws)) + assert meta["status"] == "complete" + assert "- (idea) keep me" in body + + +def test_set_can_add_new_field(ws): + init(ws) + memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"]) + assert memlog.split(read(ws))[0]["owner"] == "BMad" + + +def test_updated_stays_last(ws): + init(ws) + memlog.main(["set", "--workspace", ws, "--key", "owner", "--value", "BMad"]) + meta = memlog.split(read(ws))[0] + assert list(meta)[-1] == "updated" + + +# --- robustness --------------------------------------------------------- + +def test_roundtrip_render_is_stable(ws): + init(ws) + append(ws, "one", entry_type="idea") + first = read(ws) + meta, body = memlog.split(first) + assert memlog.render(meta, body) == first + + +def test_commas_in_field_survive(ws): + init(ws, topic="cars, trains, and planes") + append(ws, "z", entry_type="idea") + meta, _ = memlog.split(read(ws)) + 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): + init(ws) + append(ws, "x", entry_type="idea") + out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert out["ok"] is True + assert out["status"] == "active" + assert out["entries"] == 1 + assert out["memlog"].endswith(MEMLOG) + assert "section" not in out # sections are gone + + +def test_ack_entry_count_climbs(ws, capsys): + init(ws) + append(ws, "a") + append(ws, "b") + out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert out["entries"] == 2 diff --git a/src/core-skills/module-help.csv b/src/core-skills/module-help.csv index ea4abb043..22b026ed8 100644 --- a/src/core-skills/module-help.csv +++ b/src/core-skills/module-help.csv @@ -11,3 +11,4 @@ Core,bmad-review-adversarial-general,Adversarial Review,AR,"Use for quality assu Core,bmad-review-edge-case-hunter,Edge Case Hunter Review,ECH,Use alongside adversarial review for orthogonal coverage — method-driven not attitude-driven.,,[path],anytime,,,false,, Core,bmad-spec,Spec,SP,"Use to distill any intent input (brief, PRD, transcript, brain dump, design folder, mixed multi-source) into a succinct, no-fluff SPEC.md contract + companions that downstream work derives from. Locks the WHAT before the HOW. Works for software, game design, research, editorial, policy, business, anything intent-bearing. Validation mode also available.",,[path],anytime,,,false,{output_folder}/specs/spec-{slug},SPEC.md + companion files Core,bmad-customize,BMad Customize,BC,"Use when you want to change how an agent or workflow behaves — add persistent facts, swap templates, insert activation hooks, or customize menus. Scans what's customizable, picks the right scope (agent vs workflow), writes the override to _bmad/custom/, and verifies the merge. No TOML hand-authoring required.",,,anytime,,,false,{project-root}/_bmad/custom,TOML override files +Core,bmad-forge-idea,Forge Idea,FI,"Use to pressure-test and harden an idea — software, business, creative, research, or life — until it proves out, hardens into something buildable, or dies cheaply. Persona-driven interrogation; optional handoff to bmad-spec or bmad-quick-dev.",,,anytime,,,false,{output_folder}/forge,refined-idea brief (optional)