Skill Tracer
Skills › BMAD Method › bmad › memlog.py

memlog.py

290 lines · skills/bmad/scripts/memlog.py at bd70c64

1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# ///
5"""memlog — an append-only memory log: LLM-optimal working memory for a skill.
6
7A memlog is the dense, chronological record of everything that mattered in a piece of
8work — every item the user generated or accepted — kept minimal like human memory: only
9what's important, never bloated. It persists ACROSS sessions, so a fresh session can
10load it and continue. It is NOT a deliverable; downstream artifacts (a brief, a PRD, a
11deck, a report) are *derived* from it on demand. The host skill supplies the vocabulary
12by how it calls `append` — the tool stays neutral.
14It is a FLAT log: there are no sections or grouping. Every entry is one line, recorded
15at the END in the order it happened. The chronology itself is the structure — an event
16like "started technique X" is just another entry, same as an idea or an insight.
18Three invariants make it trustworthy:
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 a context-free write and echoes the new state
24 as one line of JSON, so the caller never re-reads the file mid-session. `append` adds
25 its line with one OS append write, so concurrent appenders never drop each other.
26 The one time the file is read is on resume — and the caller reads it itself, not
27 via this script.
28 3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done,
29 blocked, or paused is itself a fact that happened, so it is recorded as an entry
30 (e.g. `append --type event --text "session complete"`), never as frontmatter the
31 log would have to mutate. The chronology stays the single source of truth, and a
32 resume learns the state by reading the last entries — the same way it learns
33 everything else.
35Atomicity: `init` and `set` write a temp file, flush and fsync it, then atomically rename
36it over the target. `append` never rewrites the file: it adds the entry at the end of file
37in a single append write (O_APPEND on POSIX, a FILE_APPEND_DATA handle on Windows), which the
38OS places after whatever other processes appended, so parallel appends all land.
40The file shape (.memlog.md):
42 ---
43 topic: Onboarding flow for a budgeting app
44 goal: lift week-1 retention
45 updated: 2026-06-07T14:22
46 ---
48 - (note) user picked techniques: SCAMPER, then Six Thinking Hats
49 - (technique) started SCAMPER
50 - (idea) skip the signup wall: let people try with sample data first
51 - (idea) auto-import one bank account so the first screen shows real numbers
52 - (question) is open-banking consent too heavy for step one?
53 - (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
54 - (direction) optimize for the anxious first-timer, not the power user
55 - (decision) lead with one pre-categorized account; defer multi-account import
56 - (event) session complete
58Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
59decision, direction, assumption, gap, note, event, …) — and an optional `--by` naming
60who it came from (e.g. `user`, `coach`), for sessions where authorship matters. Both
61render into one short inline tag: `(idea)`, `(idea by user)`, `(by coach)`. Omit them
62for a plain note. The host skill names the vocabulary; the script does not enforce one.
64Commands:
65 init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists)
66 append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end
67 set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field
69Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md.
70`--path` points straight at the memlog file instead, for callers that already hold the path.
71"""
73from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+
75import argparse
76import json
77import os
78import sys
79from datetime import datetime
80from pathlib import Path
82MEMLOG = ".memlog.md"
85def now() -> str:
86 return datetime.now().strftime("%Y-%m-%dT%H:%M")
89def resolve(args) -> Path:
90 """The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path."""
91 return Path(args.path) if args.path else Path(args.workspace) / MEMLOG
94def split(text: str) -> tuple[dict, str]:
95 """Return (frontmatter dict in source order, body str). Frontmatter is plain key: value.
97 The closing fence is the first line that is *exactly* `---`, so a `---` inside a
98 field value (topic/goal are free user text) never truncates the frontmatter.
99 """
100 lines = text.splitlines()
101 if not lines or lines[0] != "---":
102 raise ValueError(".memlog.md has no frontmatter")
103 end = next((i for i in range(1, len(lines)) if lines[i] == "---"), None)
104 if end is None:
105 raise ValueError(".memlog.md frontmatter is not terminated")
106 meta: dict[str, str] = {}
107 for line in lines[1:end]:
108 if ":" in line:
109 k, v = line.split(":", 1)
110 meta[k.strip()] = v.strip()
111 return meta, "\n".join(lines[end + 1 :]).lstrip("\n")
114def render(meta: dict, body: str) -> str:
115 # Neutralize newlines in values so a multi-line field can't break the fence on re-read.
116 fm = "\n".join(f"{k}: {' '.join(str(v).splitlines())}" for k, v in meta.items())
117 return "---\n" + fm + "\n---\n\n" + body.rstrip("\n") + "\n"
120def touch(meta: dict) -> None:
121 """Stamp `updated` and keep it last so the field order stays predictable."""
122 meta.pop("updated", None)
123 meta["updated"] = now()
126def write_atomic(path: Path, text: str) -> None:
127 """Temp + flush + fsync + atomic rename, so a crash never half-writes an entry."""
128 tmp = path.with_suffix(path.suffix + ".tmp")
129 with open(tmp, "w", encoding="utf-8") as f:
130 f.write(text)
131 f.flush()
132 os.fsync(f.fileno())
133 os.replace(tmp, path)
136def append_line(path: Path, text: str) -> None:
137 """Add text at end of file in one OS append write, then sync it to disk. Never creates the file."""
138 data = text.replace("\n", os.linesep).encode("utf-8") # match text-mode line endings
139 if sys.platform != "win32":
140 fd = os.open(path, os.O_WRONLY | os.O_APPEND)
141 try:
142 if os.write(fd, data) != len(data):
143 raise OSError(f"short write appending to {path}")
144 os.fsync(fd)
145 finally:
146 os.close(fd)
147 return
148 # The CRT's append mode seeks then writes in two steps; a handle with only
149 # FILE_APPEND_DATA access makes Windows itself place every write at end of file.
150 import ctypes
151 from ctypes import wintypes
153 k32 = ctypes.WinDLL("kernel32", use_last_error=True)
154 k32.CreateFileW.argtypes = [
155 wintypes.LPCWSTR,
156 wintypes.DWORD,
157 wintypes.DWORD,
158 ctypes.c_void_p,
159 wintypes.DWORD,
160 wintypes.DWORD,
161 wintypes.HANDLE,
162 ]
163 k32.CreateFileW.restype = wintypes.HANDLE
164 k32.WriteFile.argtypes = [
165 wintypes.HANDLE,
166 ctypes.c_char_p,
167 wintypes.DWORD,
168 ctypes.POINTER(wintypes.DWORD),
169 ctypes.c_void_p,
170 ]
171 k32.WriteFile.restype = wintypes.BOOL
172 k32.FlushFileBuffers.argtypes = [wintypes.HANDLE]
173 k32.FlushFileBuffers.restype = wintypes.BOOL
174 k32.CloseHandle.argtypes = [wintypes.HANDLE]
175 file_append_data, share_all, open_existing, normal = 0x4, 0x7, 3, 0x80
176 handle = k32.CreateFileW(str(path), file_append_data, share_all, None, open_existing, normal, None)
177 if handle is None or handle == wintypes.HANDLE(-1).value:
178 raise ctypes.WinError(ctypes.get_last_error())
179 try:
180 written = wintypes.DWORD()
181 if not k32.WriteFile(handle, data, len(data), ctypes.byref(written), None):
182 raise ctypes.WinError(ctypes.get_last_error())
183 if written.value != len(data):
184 raise OSError(f"short write appending to {path}")
185 if not k32.FlushFileBuffers(handle):
186 raise ctypes.WinError(ctypes.get_last_error())
187 finally:
188 k32.CloseHandle(handle)
191def entry_count(body: str) -> int:
192 return sum(1 for ln in body.splitlines() if ln.startswith("- "))
195def ack(path: Path, body: str) -> None:
196 """Echo new state so the caller never re-reads the file to know where it stands."""
197 print(
198 json.dumps(
199 {
200 "ok": True,
201 "memlog": str(path),
202 "entries": entry_count(body),
203 }
204 )
205 )
208def cmd_init(args) -> int:
209 path = resolve(args)
210 if path.exists():
211 print(f"error: {path} already exists; use append/set to update it", file=sys.stderr)
212 return 2
213 path.parent.mkdir(parents=True, exist_ok=True)
214 meta: dict[str, str] = {}
215 for pair in args.field or []:
216 if "=" not in pair:
217 print(f"error: --field expects key=value, got {pair!r}", file=sys.stderr)
218 return 2
219 k, v = pair.split("=", 1)
220 meta[k.strip()] = v.strip()
221 touch(meta)
222 write_atomic(path, render(meta, ""))
223 ack(path, "")
224 return 0
227def cmd_append(args) -> int:
228 path = resolve(args)
229 raw = path.read_text(encoding="utf-8")
230 split(raw) # a missing or malformed log fails here, before anything is written
231 text = " ".join(args.text.split()) # collapse newlines/runs → one-line entry, no prose bloat
232 label = args.type or ""
233 if args.by:
234 label = f"{label} by {args.by}".strip() # attribution: "(idea by user)" / "(by coach)"
235 tag = f"({label}) " if label else ""
236 entry = f"- {tag}{text}"
237 append_line(path, ("" if raw.endswith("\n") else "\n") + entry + "\n")
238 ack(path, split(path.read_text(encoding="utf-8"))[1])
239 return 0
242def cmd_set(args) -> int:
243 path = resolve(args)
244 meta, body = split(path.read_text(encoding="utf-8"))
245 meta[args.key] = args.value
246 touch(meta)
247 write_atomic(path, render(meta, body))
248 ack(path, body)
249 return 0
252def add_target(sp) -> None:
253 """Every command addresses the memlog the same way: a run folder or an explicit path."""
254 g = sp.add_mutually_exclusive_group(required=True)
255 g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md")
256 g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)")
259def main(argv: list[str] | None = None) -> int:
260 p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
261 sub = p.add_subparsers(dest="cmd", required=True)
263 pi = sub.add_parser("init", help="create the memlog")
264 add_target(pi)
265 pi.add_argument("--field", action="append", metavar="KEY=VALUE", help="frontmatter field (repeatable)")
266 pi.set_defaults(func=cmd_init)
268 pa = sub.add_parser("append", help="append one entry at the end")
269 add_target(pa)
270 pa.add_argument("--text", required=True)
271 pa.add_argument("--type", help="entry kind, rendered as an inline tag")
272 pa.add_argument("--by", help="who the entry came from (e.g. user, coach); rendered into the tag")
273 pa.set_defaults(func=cmd_append)
275 pset = sub.add_parser("set", help="set a descriptive frontmatter field")
276 add_target(pset)
277 pset.add_argument("--key", required=True)
278 pset.add_argument("--value", required=True)
279 pset.set_defaults(func=cmd_set)
281 args = p.parse_args(argv)
282 return args.func(args)
285if __name__ == "__main__":
286 if sys.platform == "win32":
287 # Piped output on Windows defaults to a legacy code page, not UTF-8.
288 sys.stdout.reconfigure(encoding="utf-8")
289 sys.stderr.reconfigure(encoding="utf-8")
290 sys.exit(main())