From 908251b2c6e3607b42d53c9f3f3f7eb1a5eda22a Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Fri, 19 Jun 2026 23:35:02 -0500 Subject: [PATCH 1/2] Standardize builders on runtime-installed memlog Reference {project-root}/_bmad/scripts/memlog.py (sourced from bmm core src/scripts) instead of bundling a per-skill copy, in bmad-workflow-builder and bmad-agent-builder. Remove the bundled memlog.py copies and the workflow-builder memlog test; rewrite the working-state teaching so produced skills reference the runtime CLI rather than copying it in. --- skills/bmad-agent-builder/SKILL.md | 2 +- .../references/build-process.md | 2 +- .../references/quality-analysis.md | 2 +- skills/bmad-agent-builder/scripts/memlog.py | 198 -------------- skills/bmad-workflow-builder/SKILL.md | 2 +- .../assets/SKILL-template.md | 2 +- .../references/build-process.md | 2 +- .../references/scan-orchestration.md | 2 +- .../references/skill-quality-principles.md | 2 +- .../references/working-state-patterns.md | 4 +- .../bmad-workflow-builder/scripts/memlog.py | 197 -------------- .../scripts/tests/test_memlog.py | 247 ------------------ 12 files changed, 10 insertions(+), 652 deletions(-) delete mode 100644 skills/bmad-agent-builder/scripts/memlog.py delete mode 100644 skills/bmad-workflow-builder/scripts/memlog.py delete mode 100644 skills/bmad-workflow-builder/scripts/tests/test_memlog.py diff --git a/skills/bmad-agent-builder/SKILL.md b/skills/bmad-agent-builder/SKILL.md index 51803e5..f7f7ea1 100644 --- a/skills/bmad-agent-builder/SKILL.md +++ b/skills/bmad-agent-builder/SKILL.md @@ -35,7 +35,7 @@ The builder produces agents along one gradient surfaced as feature decisions, no 4. **Open the floor (interactive only).** Before any structured questions or routing, invite the user to share everything in mind: who the agent is, how it should make them feel, the core outcome, examples, half-formed ideas, paths to existing agents or artifacts. Adapt the invitation to what they already gave you, then one soft "anything else?" surfaces what they almost forgot. This dump replaces most downstream questioning, so let it run. Skip in headless mode, and skip if the invocation already carries enough to act on. -5. **Resume detection.** Once a target agent is identified, glob `{target-agent-path}/.memlog.md`. If one exists, read it once in full to rebuild the prior session's state, then continue append-only through `scripts/memlog.py`. This `.memlog.md` is the builder's process log and is separate from the agent's sanctum. In headless mode, resume automatically. +5. **Resume detection.** Once a target agent is identified, glob `{target-agent-path}/.memlog.md`. If one exists, read it once in full to rebuild the prior session's state, then continue append-only through `{project-root}/_bmad/scripts/memlog.py`. This `.memlog.md` is the builder's process log and is separate from the agent's sanctum. In headless mode, resume automatically. 6. **Route to the intent.** Pick the path below from the resolved intent and load only that file. Once the intent is routed, execute each entry in `{agent.activation_steps_append}` in order before the loop begins. diff --git a/skills/bmad-agent-builder/references/build-process.md b/skills/bmad-agent-builder/references/build-process.md index e8e6ffa..ac5d772 100644 --- a/skills/bmad-agent-builder/references/build-process.md +++ b/skills/bmad-agent-builder/references/build-process.md @@ -25,7 +25,7 @@ The dump tells you what the user pictured; offer what they did not. Before draft ## Capture into the memlog throughout -As decisions and directions land, write them to `{target-agent-path}/.memlog.md` through `scripts/memlog.py`: `init --path {target-agent-path}/.memlog.md` once when the target is named, then `append --path {target-agent-path}/.memlog.md --type --text "..."` as things happen. For a new agent, propose a kebab-case name when the user did not give one; renaming later is a logged decision, not a redo. This `.memlog.md` is the builder's process trace beside the built agent's SKILL.md, never the agent's sanctum — a memlog entry records a build decision, sanctum content is the agent's living runtime state, and neither ever holds the other's material. Capture as you go so the reasoning is caught while fresh, because the memlog is the resume source and the trail you walk with the user at handoff. +As decisions and directions land, write them to `{target-agent-path}/.memlog.md` through `{project-root}/_bmad/scripts/memlog.py`: `init --path {target-agent-path}/.memlog.md` once when the target is named, then `append --path {target-agent-path}/.memlog.md --type --text "..."` as things happen. For a new agent, propose a kebab-case name when the user did not give one; renaming later is a logged decision, not a redo. This `.memlog.md` is the builder's process trace beside the built agent's SKILL.md, never the agent's sanctum — a memlog entry records a build decision, sanctum content is the agent's living runtime state, and neither ever holds the other's material. Capture as you go so the reasoning is caught while fresh, because the memlog is the resume source and the trail you walk with the user at handoff. ## Write the minimal outcome-driven version first diff --git a/skills/bmad-agent-builder/references/quality-analysis.md b/skills/bmad-agent-builder/references/quality-analysis.md index b29b096..5910402 100644 --- a/skills/bmad-agent-builder/references/quality-analysis.md +++ b/skills/bmad-agent-builder/references/quality-analysis.md @@ -153,7 +153,7 @@ If the script refuses, fix `findings.json` and re-run; never hand-edit the HTML. Append one memlog event carrying the grade (init the memlog first if `{target-agent-path}/.memlog.md` does not exist): ```bash -python3 scripts/memlog.py append --path {target-agent-path}/.memlog.md --type event --text "analyze: grade , critical / high / medium / low, report .analysis//agent-analysis-report.html" +python3 {project-root}/_bmad/scripts/memlog.py append --path {target-agent-path}/.memlog.md --type event --text "analyze: grade , critical / high / medium / low, report .analysis//agent-analysis-report.html" ``` ## Present diff --git a/skills/bmad-agent-builder/scripts/memlog.py b/skills/bmad-agent-builder/scripts/memlog.py deleted file mode 100644 index a76c75f..0000000 --- a/skills/bmad-agent-builder/scripts/memlog.py +++ /dev/null @@ -1,198 +0,0 @@ -# vendored from bmad-workflow-builder/scripts; canonical source there -#!/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 decision, direction, assumption, gap, note, and event as it happened -- -kept minimal like human memory: only what is important, never bloated. It persists -ACROSS sessions, so a fresh session can load it once and continue. It is NOT a -deliverable; downstream artifacts (a brief, a PRD, a report) are derived from it on -demand. - -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. - -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, edited, or removed. There is no - edit or delete subcommand by design; history is never rewritten. - 2. Write-only / blind. Every command is an atomic, context-free write and echoes the - new state as one line of 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. - -Atomicity: every write goes to a temp file, is flushed and fsync'd, then atomically -renamed over the target, so a crash never leaves a half-written entry. - -The file shape (.memlog.md): - - --- - subject: Onboarding flow for a budgeting app - status: active - updated: 2026-06-06T14:22 - --- - - - (note) user picked the lean draft path - - (decision) lead with one pre-categorized account; defer multi-account import - - (direction) optimize for the anxious first-timer, not the power user - - (assumption) open-banking consent is available in the target market - - (gap) no data yet on week-1 retention baseline - - (event) ran baseline eval mode - -Each entry carries a typed tag drawn from a fixed vocabulary so the chronology stays -machine-scannable: decision, direction, assumption, gap, note, event. - -Commands: - init --path FILE [--field k=v ...] create the memlog (errors if it exists) - append --path FILE --type T --text STR append one typed entry at the end - set-complete --path FILE flip frontmatter status to complete - -The path is the memlog file itself (conventionally {run-folder}/.memlog.md). -""" -import argparse -import json -import os -import sys -from datetime import datetime -from pathlib import Path - -ENTRY_TYPES = ("decision", "direction", "assumption", "gap", "note", "event") - - -def now() -> str: - return datetime.now().strftime("%Y-%m-%dT%H:%M") - - -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 (subject is 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: - """Temp + flush + fsync + atomic rename, so a crash never half-writes an entry.""" - tmp = path.with_suffix(path.suffix + ".tmp") - with open(tmp, "w", encoding="utf-8") as f: - f.write(text) - f.flush() - os.fsync(f.fileno()) - 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, entry_type: str = "") -> None: - """Echo new state so the caller never re-reads the file to know where it stands.""" - out = { - "ok": True, - "memlog": str(path), - "status": meta.get("status", ""), - "n": entry_count(body), - } - if entry_type: - out["type"] = entry_type - print(json.dumps(out)) - - -def cmd_init(args) -> int: - path = Path(args.path) - if path.exists(): - print(f"error: {path} already exists; use append/set-complete 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 = Path(args.path) - if args.type not in ENTRY_TYPES: - print(f"error: --type must be one of {', '.join(ENTRY_TYPES)}; got {args.type!r}", file=sys.stderr) - return 2 - meta, body = split(path.read_text(encoding="utf-8")) - text = " ".join(args.text.split()) # collapse newlines/runs -> one-line entry - entry = f"- ({args.type}) {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, args.type) - return 0 - - -def cmd_set_complete(args) -> int: - path = Path(args.path) - meta, body = split(path.read_text(encoding="utf-8")) - meta["status"] = "complete" - 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("--path", required=True, help="memlog file path (e.g. {run-folder}/.memlog.md)") - 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 typed entry at the end") - pa.add_argument("--path", required=True) - pa.add_argument("--type", required=True, choices=ENTRY_TYPES, help="entry kind") - pa.add_argument("--text", required=True) - pa.set_defaults(func=cmd_append) - - pc = sub.add_parser("set-complete", help="flip frontmatter status to complete") - pc.add_argument("--path", required=True) - pc.set_defaults(func=cmd_set_complete) - - args = p.parse_args(argv) - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/bmad-workflow-builder/SKILL.md b/skills/bmad-workflow-builder/SKILL.md index 6607692..4912e0f 100644 --- a/skills/bmad-workflow-builder/SKILL.md +++ b/skills/bmad-workflow-builder/SKILL.md @@ -25,7 +25,7 @@ Act as a skill-building partner who turns a half-formed idea in the user's head 4. **Open the floor (interactive only).** Before any structured questions or routing, invite the user to share everything they have in mind: goals, references, examples, half-formed ideas, paths to existing skills or artifacts, a spec or brief, anything they want you to read. Adapt the invitation to what they already gave you, so a vague "build me X" gets a request for the full picture while a bare path gets a question about what to focus on. After they share, one soft "anything else?" surfaces what they almost forgot. This dump replaces most of the downstream questioning, so let it run. Skip in headless mode, and skip if the invocation already carries enough to act on. -5. **Resume detection.** Once a target skill is identified, glob `{target-skill-path}/.memlog.md`. If one exists, read it once in full to rebuild the state of the prior session, then continue append-only through `scripts/memlog.py`. Never look for `.decision-log.md`; the memlog is the only process memory. In headless mode, resume automatically. +5. **Resume detection.** Once a target skill is identified, glob `{target-skill-path}/.memlog.md`. If one exists, read it once in full to rebuild the state of the prior session, then continue append-only through `{project-root}/_bmad/scripts/memlog.py`. Never look for `.decision-log.md`; the memlog is the only process memory. In headless mode, resume automatically. 6. **Route to the intent.** Pick the path below from the resolved intent and load only that file. diff --git a/skills/bmad-workflow-builder/assets/SKILL-template.md b/skills/bmad-workflow-builder/assets/SKILL-template.md index fa8381f..8c5eb6a 100644 --- a/skills/bmad-workflow-builder/assets/SKILL-template.md +++ b/skills/bmad-workflow-builder/assets/SKILL-template.md @@ -46,7 +46,7 @@ Write it once; do not restate it lower down.} 1. Load config from `{project-root}/_bmad/config.yaml` (and `.user.yaml` if present). Use sensible defaults for anything missing rather than requiring configuration. -2. Resume check. Look for an existing `.memlog.md` in the run folder. If one is found, read it once to rebuild state and continue append-only; otherwise initialize a new memlog with `python3 scripts/memlog.py init --path /.memlog.md`. +2. Resume check. Look for an existing `.memlog.md` in the run folder. If one is found, read it once to rebuild state and continue append-only; otherwise initialize a new memlog with `python3 {project-root}/_bmad/scripts/memlog.py init --path /.memlog.md`. 3. Resolve the `workflow` block: run `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. If the script fails, merge these three files yourself in base → team → user order — `{skill-root}/customize.toml`, `{project-root}/_bmad/custom/{skill-name}.toml`, `{project-root}/_bmad/custom/{skill-name}.user.toml` — where scalars override, tables deep-merge, arrays of tables keyed by `code`/`id` replace matching entries and append new ones, and all other arrays append. Reference resolved values as `{workflow.}` everywhere below; never hardcode a path beside a declared scalar. diff --git a/skills/bmad-workflow-builder/references/build-process.md b/skills/bmad-workflow-builder/references/build-process.md index 52c1519..1bd8cf1 100644 --- a/skills/bmad-workflow-builder/references/build-process.md +++ b/skills/bmad-workflow-builder/references/build-process.md @@ -28,7 +28,7 @@ Hardening cuts the idea down; this builds it out. Before drafting, offer what th ## Capture continuously into the memlog -As decisions and directions land, write them to `{target-skill-path}/.memlog.md` through `scripts/memlog.py` (`init` once when the target is named, then `append --type ` as things happen). For a new skill, propose a kebab-case name when the user did not give one; renaming later is a logged decision, not a redo. The memlog is the canonical process memory, the source for resume, and the trail you audit at handoff so the user can confirm their thinking was handled the way they meant. Capture as you go, not in a batch at the end, because the value is in catching the reasoning while it is still fresh. +As decisions and directions land, write them to `{target-skill-path}/.memlog.md` through `{project-root}/_bmad/scripts/memlog.py` (`init` once when the target is named, then `append --type ` as things happen). For a new skill, propose a kebab-case name when the user did not give one; renaming later is a logged decision, not a redo. The memlog is the canonical process memory, the source for resume, and the trail you audit at handoff so the user can confirm their thinking was handled the way they meant. Capture as you go, not in a batch at the end, because the value is in catching the reasoning while it is still fresh. ## Write the minimal outcome-driven version first diff --git a/skills/bmad-workflow-builder/references/scan-orchestration.md b/skills/bmad-workflow-builder/references/scan-orchestration.md index 8f98cfc..edddf63 100644 --- a/skills/bmad-workflow-builder/references/scan-orchestration.md +++ b/skills/bmad-workflow-builder/references/scan-orchestration.md @@ -109,7 +109,7 @@ The shell fails loud: a malformed island shows the parse-error banner, an unfill Append one memlog event carrying the grade (init the memlog first if `{target-skill-path}/.memlog.md` does not exist): ```bash -python3 scripts/memlog.py append --path {target-skill-path}/.memlog.md --type event --text "analyze: grade , critical / high / medium / low, report .analysis//skill-analysis-report.html" +python3 {project-root}/_bmad/scripts/memlog.py append --path {target-skill-path}/.memlog.md --type event --text "analyze: grade , critical / high / medium / low, report .analysis//skill-analysis-report.html" ``` ## Present diff --git a/skills/bmad-workflow-builder/references/skill-quality-principles.md b/skills/bmad-workflow-builder/references/skill-quality-principles.md index 38b7b22..47c00a7 100644 --- a/skills/bmad-workflow-builder/references/skill-quality-principles.md +++ b/skills/bmad-workflow-builder/references/skill-quality-principles.md @@ -46,7 +46,7 @@ Default: write the entire workflow as named sections in SKILL.md (`## Discovery` - **Gotchas stay in SKILL.md.** A rule whose trigger the model cannot recognize — a soft-delete column that poisons queries, a health endpoint that lies, three names for one ID — never carves to a reference however branch-specific it is, because the model cannot load a file for a situation it does not know it is in. When a user corrects a running skill, the cheapest durable fix is appending that correction as a gotcha line. ## Headless mode -When a skill supports headless invocation, the memlog absorbs every assumption made without the user: intent inference, proposed names, customization defaults, conflict resolutions, lint-fix calls, anything the user would have weighed in on interactively. Append these as typed `assumption` and `decision` entries through `scripts/memlog.py` as they happen. The JSON return is the smallest set of paths the caller needs (typically `skill` plus the memlog path, plus the report path for analysis flows); the memlog carries the reasoning. `status` is `complete` or `blocked`; on `blocked`, include a one-line `reason` and still return the memlog path so the caller can read the detail. Without this discipline, headless silently buries its calls and the audit trail breaks on the next session. +When a skill supports headless invocation, the memlog absorbs every assumption made without the user: intent inference, proposed names, customization defaults, conflict resolutions, lint-fix calls, anything the user would have weighed in on interactively. Append these as typed `assumption` and `decision` entries through `{project-root}/_bmad/scripts/memlog.py` as they happen. The JSON return is the smallest set of paths the caller needs (typically `skill` plus the memlog path, plus the report path for analysis flows); the memlog carries the reasoning. `status` is `complete` or `blocked`; on `blocked`, include a one-line `reason` and still return the memlog path so the caller can read the detail. Without this discipline, headless silently buries its calls and the audit trail breaks on the next session. ## Subagent constraints - Subagents CANNOT spawn other subagents. Chain through the parent. diff --git a/skills/bmad-workflow-builder/references/working-state-patterns.md b/skills/bmad-workflow-builder/references/working-state-patterns.md index 421bc0d..4a21611 100644 --- a/skills/bmad-workflow-builder/references/working-state-patterns.md +++ b/skills/bmad-workflow-builder/references/working-state-patterns.md @@ -19,11 +19,11 @@ memlog and the structured artifact are not rivals. memlog is *meta* about the wo For a skill whose value includes the reasoning behind the deliverable. The memlog carries identity across sessions, keeps the agent from railroading the user, surfaces conflicts on update, and creates an audit trail when the user overrides a past call. A skill that needs it looks fine on the first pass and falls apart on revisit without it. -The memlog is typed, append-only, and written through `scripts/memlog.py` to a `.memlog.md` file beside the primary artifact. The model never edits or re-reads it mid-session; it appends one typed entry at a time and trusts the one-line JSON ack. The cycle is capture (append as decisions and directions land), distill (at finalize, account for every entry), and project (read the whole log once on resume or when building a summary). +The memlog is typed, append-only, and written through `{project-root}/_bmad/scripts/memlog.py` to a `.memlog.md` file beside the primary artifact. The model never edits or re-reads it mid-session; it appends one typed entry at a time and trusts the one-line JSON ack. The cycle is capture (append as decisions and directions land), distill (at finalize, account for every entry), and project (read the whole log once on resume or when building a summary). ### Entry types and the CLI -The CLI ships with the skill that calls it. When a built skill adopts a memlog, copy `memlog.py` from this builder's `scripts/` into the built skill's `scripts/` at emit — the bare `scripts/memlog.py` path resolves from the built skill's own root, so an uncopied CLI fails on the first `init`. +The memlog CLI is runtime-installed at `{project-root}/_bmad/scripts/memlog.py`; a built skill calls it there and bundles no copy of its own. The `{project-root}` token resolves at runtime, so the same invocation works from any skill's root. - `init --path ` creates the log. - `append --path --type --text ` adds one typed entry; `` is one of `decision`, `direction`, `assumption`, `gap`, `note`, `event`. diff --git a/skills/bmad-workflow-builder/scripts/memlog.py b/skills/bmad-workflow-builder/scripts/memlog.py deleted file mode 100644 index 504fad6..0000000 --- a/skills/bmad-workflow-builder/scripts/memlog.py +++ /dev/null @@ -1,197 +0,0 @@ -#!/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 decision, direction, assumption, gap, note, and event as it happened -- -kept minimal like human memory: only what is important, never bloated. It persists -ACROSS sessions, so a fresh session can load it once and continue. It is NOT a -deliverable; downstream artifacts (a brief, a PRD, a report) are derived from it on -demand. - -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. - -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, edited, or removed. There is no - edit or delete subcommand by design; history is never rewritten. - 2. Write-only / blind. Every command is an atomic, context-free write and echoes the - new state as one line of 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. - -Atomicity: every write goes to a temp file, is flushed and fsync'd, then atomically -renamed over the target, so a crash never leaves a half-written entry. - -The file shape (.memlog.md): - - --- - subject: Onboarding flow for a budgeting app - status: active - updated: 2026-06-06T14:22 - --- - - - (note) user picked the lean draft path - - (decision) lead with one pre-categorized account; defer multi-account import - - (direction) optimize for the anxious first-timer, not the power user - - (assumption) open-banking consent is available in the target market - - (gap) no data yet on week-1 retention baseline - - (event) ran baseline eval mode - -Each entry carries a typed tag drawn from a fixed vocabulary so the chronology stays -machine-scannable: decision, direction, assumption, gap, note, event. - -Commands: - init --path FILE [--field k=v ...] create the memlog (errors if it exists) - append --path FILE --type T --text STR append one typed entry at the end - set-complete --path FILE flip frontmatter status to complete - -The path is the memlog file itself (conventionally {run-folder}/.memlog.md). -""" -import argparse -import json -import os -import sys -from datetime import datetime -from pathlib import Path - -ENTRY_TYPES = ("decision", "direction", "assumption", "gap", "note", "event") - - -def now() -> str: - return datetime.now().strftime("%Y-%m-%dT%H:%M") - - -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 (subject is 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: - """Temp + flush + fsync + atomic rename, so a crash never half-writes an entry.""" - tmp = path.with_suffix(path.suffix + ".tmp") - with open(tmp, "w", encoding="utf-8") as f: - f.write(text) - f.flush() - os.fsync(f.fileno()) - 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, entry_type: str = "") -> None: - """Echo new state so the caller never re-reads the file to know where it stands.""" - out = { - "ok": True, - "memlog": str(path), - "status": meta.get("status", ""), - "n": entry_count(body), - } - if entry_type: - out["type"] = entry_type - print(json.dumps(out)) - - -def cmd_init(args) -> int: - path = Path(args.path) - if path.exists(): - print(f"error: {path} already exists; use append/set-complete 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 = Path(args.path) - if args.type not in ENTRY_TYPES: - print(f"error: --type must be one of {', '.join(ENTRY_TYPES)}; got {args.type!r}", file=sys.stderr) - return 2 - meta, body = split(path.read_text(encoding="utf-8")) - text = " ".join(args.text.split()) # collapse newlines/runs -> one-line entry - entry = f"- ({args.type}) {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, args.type) - return 0 - - -def cmd_set_complete(args) -> int: - path = Path(args.path) - meta, body = split(path.read_text(encoding="utf-8")) - meta["status"] = "complete" - 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("--path", required=True, help="memlog file path (e.g. {run-folder}/.memlog.md)") - 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 typed entry at the end") - pa.add_argument("--path", required=True) - pa.add_argument("--type", required=True, choices=ENTRY_TYPES, help="entry kind") - pa.add_argument("--text", required=True) - pa.set_defaults(func=cmd_append) - - pc = sub.add_parser("set-complete", help="flip frontmatter status to complete") - pc.add_argument("--path", required=True) - pc.set_defaults(func=cmd_set_complete) - - args = p.parse_args(argv) - return args.func(args) - - -if __name__ == "__main__": - sys.exit(main()) diff --git a/skills/bmad-workflow-builder/scripts/tests/test_memlog.py b/skills/bmad-workflow-builder/scripts/tests/test_memlog.py deleted file mode 100644 index 5bbb295..0000000 --- a/skills/bmad-workflow-builder/scripts/tests/test_memlog.py +++ /dev/null @@ -1,247 +0,0 @@ -# /// 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 typed line recorded at the end in the order it happened -- no sections, no grouping, -no edit, no removal. -""" -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 path(tmp_path): - return str(tmp_path / MEMLOG) - - -def read(path): - return Path(path).read_text(encoding="utf-8") - - -def body_of(path): - return memlog.split(read(path))[1] - - -def entries(path): - return [ln for ln in body_of(path).splitlines() if ln.startswith("- ")] - - -def init(path, **fields): - fields = fields or {"subject": "Reinvent the lunchbox"} - argv = ["init", "--path", path] - for k, v in fields.items(): - argv += ["--field", f"{k}={v}"] - assert memlog.main(argv) == 0 - - -def append(path, entry_type, text): - assert memlog.main(["append", "--path", path, "--type", entry_type, "--text", text]) == 0 - - -# --- init --------------------------------------------------------------- - -def test_init_writes_frontmatter_fields(path): - init(path) - meta, body = memlog.split(read(path)) - assert meta["subject"] == "Reinvent the lunchbox" - assert meta["status"] == "active" - assert "updated" in meta - assert body.strip() == "" - - -def test_init_arbitrary_fields(path): - init(path, subject="T", owner="BMad") - meta, _ = memlog.split(read(path)) - assert meta["owner"] == "BMad" - - -def test_init_refuses_overwrite(path): - init(path) - assert memlog.main(["init", "--path", path, "--field", "subject=other"]) == 2 - - -def test_init_creates_missing_parent_dir(tmp_path): - nested = str(tmp_path / "a" / "b" / MEMLOG) - assert memlog.main(["init", "--path", nested, "--field", "subject=T"]) == 0 - assert Path(nested).is_file() - - -def test_init_rejects_malformed_field(path): - assert memlog.main(["init", "--path", path, "--field", "noequals"]) == 2 - - -# --- append: flat chronological order, typed ----------------------------- - -def test_append_lands_at_end_in_order(path): - init(path) - append(path, "note", "first") - append(path, "note", "second") - append(path, "note", "third") - assert entries(path) == ["- (note) first", "- (note) second", "- (note) third"] - - -def test_no_sections_or_headings_ever(path): - init(path) - append(path, "event", "started foo") - append(path, "note", "an idea") - append(path, "event", "started bar") - assert "## " not in body_of(path) - - -def test_type_renders_as_inline_tag(path): - init(path) - append(path, "decision", "lead with one account") - append(path, "gap", "no retention baseline yet") - body = body_of(path) - assert "- (decision) lead with one account" in body - assert "- (gap) no retention baseline yet" in body - - -def test_all_six_entry_types_accepted(path): - init(path) - for t in ("decision", "direction", "assumption", "gap", "note", "event"): - append(path, t, f"a {t}") - body = body_of(path) - for t in ("decision", "direction", "assumption", "gap", "note", "event"): - assert f"({t})" in body - - -def test_unknown_type_is_rejected(path): - init(path) - # argparse choices rejects it before our handler (exit code 2 via SystemExit) - with pytest.raises(SystemExit): - memlog.main(["append", "--path", path, "--type", "idea", "--text", "x"]) - - -def test_append_collapses_newlines_into_one_line(path): - init(path) - append(path, "note", "line one\nline two\n spaced out") - assert entries(path) == ["- (note) line one line two spaced out"] - - -# --- set-complete ------------------------------------------------------- - -def test_set_complete_flips_status(path): - init(path) - assert memlog.main(["set-complete", "--path", path]) == 0 - assert memlog.split(read(path))[0]["status"] == "complete" - - -def test_set_complete_preserves_body(path): - init(path) - append(path, "decision", "keep me") - memlog.main(["set-complete", "--path", path]) - meta, body = memlog.split(read(path)) - assert meta["status"] == "complete" - assert "- (decision) keep me" in body - - -def test_updated_stays_last(path): - init(path) - append(path, "note", "x") - meta = memlog.split(read(path))[0] - assert list(meta)[-1] == "updated" - - -# --- robustness --------------------------------------------------------- - -def test_roundtrip_render_is_stable(path): - init(path) - append(path, "note", "one") - first = read(path) - meta, body = memlog.split(first) - assert memlog.render(meta, body) == first - - -def test_commas_in_field_survive(path): - init(path, subject="cars, trains, and planes") - append(path, "note", "z") - meta, _ = memlog.split(read(path)) - assert meta["subject"] == "cars, trains, and planes" - - -def test_triple_dash_in_field_does_not_corrupt_frontmatter(path): - # A `---` inside a value must NOT be read as the closing fence. - init(path, subject="Pricing --- tiers --- and add-ons") - append(path, "note", "an idea") - meta, body = memlog.split(read(path)) - assert meta["subject"] == "Pricing --- tiers --- and add-ons" - assert meta["status"] == "active" - assert entries(path) == ["- (note) an idea"] - assert "status:" not in body - - -def test_newline_in_field_is_neutralized(path): - memlog.main(["init", "--path", path, "--field", "subject=line one\nline two"]) - append(path, "note", "x") - meta, _ = memlog.split(read(path)) - assert "\n" not in meta["subject"] - assert meta["status"] == "active" - - -# --- atomic write: no temp file lingers, no half-write ------------------ - -def test_atomic_write_leaves_no_temp_file(tmp_path): - p = str(tmp_path / MEMLOG) - init(p) - append(p, "note", "x") - assert not (tmp_path / (MEMLOG + ".tmp")).exists() - # the real file is the only memlog artifact present - leftovers = [f.name for f in tmp_path.iterdir() if f.name.endswith(".tmp")] - assert leftovers == [] - - -def test_append_survives_after_many_writes(path): - init(path) - for i in range(50): - append(path, "event", f"step {i}") - assert len(entries(path)) == 50 - assert entries(path)[0] == "- (event) step 0" - assert entries(path)[-1] == "- (event) step 49" - - -# --- JSON ack ----------------------------------------------------------- - -def test_append_emits_json_ack(path, capsys): - init(path) - append(path, "decision", "x") - out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) - assert out["ok"] is True - assert out["status"] == "active" - assert out["n"] == 1 - assert out["type"] == "decision" - assert out["memlog"].endswith(MEMLOG) - - -def test_ack_n_climbs(path, capsys): - init(path) - append(path, "note", "a") - append(path, "note", "b") - out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) - assert out["n"] == 2 - - -def test_set_complete_ack(path, capsys): - init(path) - memlog.main(["set-complete", "--path", path]) - out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) - assert out["ok"] is True - assert out["status"] == "complete" - - -def test_no_edit_or_remove_subcommand_exists(path): - init(path) - for bad in ("edit", "remove", "delete", "set"): - with pytest.raises(SystemExit): - memlog.main([bad, "--path", path]) From 0ac8340a74baba2346ab5b1b92b9363428cc6d44 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Sat, 20 Jun 2026 01:22:45 -0500 Subject: [PATCH 2/2] Standardize script invocations on uv run Replace `python3