BMAD-METHOD/src/core-skills/bmad-brainstorming/scripts/memlog.py

189 lines
6.9 KiB
Python

#!/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, …) — rendered as a short inline tag. Omit it 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] 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."""
if not text.startswith("---"):
raise ValueError(".memlog.md has no frontmatter")
_, fm, body = text.split("---", 2)
meta: dict[str, str] = {}
for line in fm.strip().splitlines():
if ":" in line:
k, v = line.split(":", 1)
meta[k.strip()] = v.strip()
return meta, body.lstrip("\n")
def render(meta: dict, body: str) -> str:
fm = "\n".join(f"{k}: {v}" 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
tag = f"({args.type}) " if args.type 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.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())