bmad 6.11
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
"""Shared strict TOML loading and structural merge support."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import tomllib
|
||||
from pathlib import Path
|
||||
from typing import Any, Iterable
|
||||
|
||||
|
||||
class ConfigError(ValueError):
|
||||
"""Raised when a present configuration layer cannot be used safely."""
|
||||
|
||||
|
||||
_KEYED_MERGE_FIELDS = ("code", "id")
|
||||
|
||||
|
||||
def load_toml(path: Path, *, required: bool = False) -> dict[str, Any]:
|
||||
"""Load a TOML table, allowing absence only for optional layers."""
|
||||
if not path.exists():
|
||||
if required:
|
||||
raise ConfigError(f"required TOML file not found: {path}")
|
||||
return {}
|
||||
if not path.is_file():
|
||||
raise ConfigError(f"TOML layer is not a file: {path}")
|
||||
try:
|
||||
with path.open("rb") as stream:
|
||||
parsed = tomllib.load(stream)
|
||||
except tomllib.TOMLDecodeError as error:
|
||||
raise ConfigError(f"failed to parse {path}: {error}") from error
|
||||
except OSError as error:
|
||||
raise ConfigError(f"failed to read {path}: {error}") from error
|
||||
if not isinstance(parsed, dict):
|
||||
raise ConfigError(f"TOML layer did not parse to a table: {path}")
|
||||
return parsed
|
||||
|
||||
|
||||
def _detect_keyed_merge_field(items: list[Any]) -> str | None:
|
||||
if not items or not all(isinstance(item, dict) for item in items):
|
||||
return None
|
||||
for candidate in _KEYED_MERGE_FIELDS:
|
||||
if all(candidate in item for item in items):
|
||||
for item in items:
|
||||
value = item[candidate]
|
||||
if not isinstance(value, str):
|
||||
raise ConfigError(
|
||||
f"keyed array identifier `{candidate}` must be a string, "
|
||||
f"got {type(value).__name__}"
|
||||
)
|
||||
if not value:
|
||||
raise ConfigError(
|
||||
f"keyed array identifier `{candidate}` must not be empty"
|
||||
)
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def _merge_arrays(base: list[Any], override: list[Any]) -> list[Any]:
|
||||
keyed_field = _detect_keyed_merge_field(base + override)
|
||||
if keyed_field is None:
|
||||
return list(base) + list(override)
|
||||
|
||||
result: list[Any] = []
|
||||
index_by_key: dict[str, int] = {}
|
||||
for item in base:
|
||||
copied = dict(item)
|
||||
index_by_key[copied[keyed_field]] = len(result)
|
||||
result.append(copied)
|
||||
for item in override:
|
||||
copied = dict(item)
|
||||
key = copied[keyed_field]
|
||||
if key in index_by_key:
|
||||
result[index_by_key[key]] = copied
|
||||
else:
|
||||
index_by_key[key] = len(result)
|
||||
result.append(copied)
|
||||
return result
|
||||
|
||||
|
||||
def structural_merge(base: Any, override: Any) -> Any:
|
||||
"""Merge tables recursively, keyed table arrays by identity, and append other arrays."""
|
||||
if isinstance(base, dict) and isinstance(override, dict):
|
||||
result = dict(base)
|
||||
for key, value in override.items():
|
||||
result[key] = structural_merge(result[key], value) if key in result else value
|
||||
return result
|
||||
if isinstance(base, list) and isinstance(override, list):
|
||||
return _merge_arrays(base, override)
|
||||
return override
|
||||
|
||||
|
||||
def merge_layers(layers: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
||||
merged: dict[str, Any] = {}
|
||||
for layer in layers:
|
||||
merged = structural_merge(merged, layer)
|
||||
return merged
|
||||
|
||||
|
||||
def load_central_config(project_root: Path) -> dict[str, Any]:
|
||||
bmad_dir = project_root / "_bmad"
|
||||
return merge_layers(
|
||||
(
|
||||
load_toml(bmad_dir / "config.toml", required=True),
|
||||
load_toml(bmad_dir / "config.user.toml"),
|
||||
load_toml(bmad_dir / "custom" / "config.toml"),
|
||||
load_toml(bmad_dir / "custom" / "config.user.toml"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def load_customization(project_root: Path | None, skill_dir: Path) -> dict[str, Any]:
|
||||
skill_name = skill_dir.name
|
||||
custom_dir = project_root / "_bmad" / "custom" if project_root else None
|
||||
return merge_layers(
|
||||
(
|
||||
load_toml(skill_dir / "customize.toml", required=True),
|
||||
load_toml(custom_dir / f"{skill_name}.toml") if custom_dir else {},
|
||||
load_toml(custom_dir / f"{skill_name}.user.toml") if custom_dir else {},
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,224 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.8"
|
||||
# ///
|
||||
"""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.
|
||||
|
||||
Three 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.
|
||||
3. No lifecycle status. A memory log has no "complete" flag. Whether the work is done,
|
||||
blocked, or paused is itself a fact that happened, so it is recorded as an entry
|
||||
(e.g. `append --type event --text "session complete"`), never as frontmatter the
|
||||
log would have to mutate. The chronology stays the single source of truth, and a
|
||||
resume learns the state by reading the last entries — the same way it learns
|
||||
everything else.
|
||||
|
||||
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):
|
||||
|
||||
---
|
||||
topic: Onboarding flow for a budgeting app
|
||||
goal: lift week-1 retention
|
||||
updated: 2026-06-07T14: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?
|
||||
- (insight) the "scary numbers" risk and the "real numbers" idea are one lever: show real data, pre-categorized
|
||||
- (direction) optimize for the anxious first-timer, not the power user
|
||||
- (decision) lead with one pre-categorized account; defer multi-account import
|
||||
- (event) session complete
|
||||
|
||||
Each entry may carry an optional `--type` — what KIND it is (idea, insight, question,
|
||||
decision, direction, assumption, gap, note, event, …) — 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 enforce one.
|
||||
|
||||
Commands:
|
||||
init (--workspace DIR | --path FILE) [--field k=v ...] create the memlog (errors if it exists)
|
||||
append (--workspace DIR | --path FILE) --text STR [--type T] [--by W] append one entry at the end
|
||||
set (--workspace DIR | --path FILE) --key K --value V set/replace a descriptive frontmatter field
|
||||
|
||||
Addressing: `--workspace` is the run folder, and the memlog is always {workspace}/.memlog.md.
|
||||
`--path` points straight at the memlog file instead, for callers that already hold the path.
|
||||
"""
|
||||
from __future__ import annotations # keep type-hint syntax lazy so the script runs on 3.8+
|
||||
|
||||
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 resolve(args) -> Path:
|
||||
"""The memlog file, from either addressing mode: {workspace}/.memlog.md or an explicit --path."""
|
||||
return Path(args.path) if args.path else Path(args.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:
|
||||
"""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, 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),
|
||||
"entries": entry_count(body),
|
||||
}))
|
||||
|
||||
|
||||
def cmd_init(args) -> int:
|
||||
path = resolve(args)
|
||||
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()
|
||||
touch(meta)
|
||||
write_atomic(path, render(meta, ""))
|
||||
ack(path, "")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_append(args) -> int:
|
||||
path = resolve(args)
|
||||
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, body)
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_set(args) -> int:
|
||||
path = resolve(args)
|
||||
meta, body = split(path.read_text(encoding="utf-8"))
|
||||
meta[args.key] = args.value
|
||||
touch(meta)
|
||||
write_atomic(path, render(meta, body))
|
||||
ack(path, body)
|
||||
return 0
|
||||
|
||||
|
||||
def add_target(sp) -> None:
|
||||
"""Every command addresses the memlog the same way: a run folder or an explicit path."""
|
||||
g = sp.add_mutually_exclusive_group(required=True)
|
||||
g.add_argument("--workspace", help="run folder; the memlog is {workspace}/.memlog.md")
|
||||
g.add_argument("--path", help="explicit memlog file path (alternative to --workspace)")
|
||||
|
||||
|
||||
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")
|
||||
add_target(pi)
|
||||
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")
|
||||
add_target(pa)
|
||||
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 descriptive frontmatter field")
|
||||
add_target(pset)
|
||||
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())
|
||||
@@ -0,0 +1,401 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Render a skill's Markdown sources into an immutable project snapshot."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Installed scripts are consumer files, not a location for interpreter caches.
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
from config_utils import ConfigError, load_central_config, load_customization, load_toml
|
||||
|
||||
|
||||
class RenderError(ValueError):
|
||||
"""Raised when rendering cannot safely publish a snapshot."""
|
||||
|
||||
|
||||
_CONFIG_TOKEN = re.compile(r"\{\{config\.([A-Za-z0-9_.-]+)\}\}")
|
||||
_SHORT_CONFIG_TOKEN = re.compile(r"\{\{\.([A-Za-z0-9_]+)\}\}")
|
||||
_CUSTOM_TOKEN = re.compile(r"\{workflow\.([A-Za-z0-9_.-]+)\}")
|
||||
_SNAPSHOT_TOKEN = re.compile(r"\[\[bmad-snapshot:([A-Za-z0-9_./-]+\.md)\]\]")
|
||||
|
||||
|
||||
def _hash_bytes(content: bytes) -> str:
|
||||
return hashlib.sha256(content).hexdigest()
|
||||
|
||||
|
||||
def _canonical_json(value: Any) -> bytes:
|
||||
return json.dumps(
|
||||
value, ensure_ascii=False, sort_keys=True, separators=(",", ":")
|
||||
).encode("utf-8")
|
||||
|
||||
|
||||
def _lookup(data: dict[str, Any], dotted_path: str, label: str) -> Any:
|
||||
current: Any = data
|
||||
for part in dotted_path.split("."):
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
raise RenderError(f"missing {label} `{dotted_path}`")
|
||||
current = current[part]
|
||||
return current
|
||||
|
||||
|
||||
def _require_string(value: Any, label: str, *, allow_empty: bool = False) -> str:
|
||||
if not isinstance(value, str):
|
||||
raise RenderError(f"{label} must be a string, got {type(value).__name__}")
|
||||
if not allow_empty and not value.strip():
|
||||
raise RenderError(f"{label} must not be empty")
|
||||
return value
|
||||
|
||||
|
||||
def _require_string_list(value: Any, label: str) -> list[str]:
|
||||
if not isinstance(value, list):
|
||||
raise RenderError(f"{label} must be a list, got {type(value).__name__}")
|
||||
result = []
|
||||
for index, item in enumerate(value):
|
||||
result.append(_require_string(item, f"{label}[{index}]"))
|
||||
return result
|
||||
|
||||
|
||||
def _require_review_layers(value: Any, label: str) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
raise RenderError(f"{label} must be a list of tables")
|
||||
result: list[dict[str, str]] = []
|
||||
seen: set[str] = set()
|
||||
for index, item in enumerate(value):
|
||||
item_label = f"{label}[{index}]"
|
||||
if not isinstance(item, dict):
|
||||
raise RenderError(f"{item_label} must be a table")
|
||||
identifier = _require_string(item.get("id"), f"{item_label}.id")
|
||||
if identifier in seen:
|
||||
raise RenderError(f"duplicate review layer id `{identifier}`")
|
||||
seen.add(identifier)
|
||||
layer = {
|
||||
"id": identifier,
|
||||
"name": _require_string(item.get("name", identifier), f"{item_label}.name"),
|
||||
"instruction": _require_string(
|
||||
item.get("instruction"), f"{item_label}.instruction", allow_empty=True
|
||||
),
|
||||
}
|
||||
if "when" in item:
|
||||
layer["when"] = _require_string(item["when"], f"{item_label}.when")
|
||||
result.append(layer)
|
||||
return result
|
||||
|
||||
|
||||
def _load_sources(skill_dir: Path) -> dict[str, str]:
|
||||
sources: dict[str, str] = {}
|
||||
for candidate in sorted(skill_dir.rglob("*.md")):
|
||||
if candidate.name == "SKILL.md":
|
||||
continue
|
||||
name = candidate.relative_to(skill_dir).as_posix()
|
||||
path = candidate.resolve(strict=True)
|
||||
if not path.is_relative_to(skill_dir):
|
||||
raise RenderError(f"render source escapes skill directory: {name}")
|
||||
if not path.is_file():
|
||||
raise RenderError(f"render source is missing or not a file: {path}")
|
||||
try:
|
||||
sources[name] = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as error:
|
||||
raise RenderError(f"failed to read render source {path}: {error}") from error
|
||||
if "workflow.md" not in sources:
|
||||
raise RenderError(f"render entry is missing: {skill_dir / 'workflow.md'}")
|
||||
return sources
|
||||
|
||||
|
||||
def _resolve_config_value(value: Any, label: str, project_root: Path) -> str:
|
||||
text = _require_string(value, label)
|
||||
if "{project-root}" not in text:
|
||||
return text
|
||||
resolved = text.replace("{project-root}", str(project_root))
|
||||
if not Path(resolved).is_absolute():
|
||||
raise RenderError(f"{label} must resolve to an absolute path: {resolved}")
|
||||
return resolved
|
||||
|
||||
|
||||
def _find_config_values(data: Any, key: str, prefix: str = "") -> list[tuple[str, Any]]:
|
||||
matches: list[tuple[str, Any]] = []
|
||||
if not isinstance(data, dict):
|
||||
return matches
|
||||
for name, value in data.items():
|
||||
path = f"{prefix}.{name}" if prefix else name
|
||||
if name == key and not isinstance(value, (dict, list)):
|
||||
matches.append((path, value))
|
||||
matches.extend(_find_config_values(value, key, path))
|
||||
return matches
|
||||
|
||||
|
||||
def _resolve_short_config(
|
||||
central: dict[str, Any], key: str, project_root: Path
|
||||
) -> tuple[str, str]:
|
||||
matches = _find_config_values(central, key)
|
||||
if not matches:
|
||||
raise RenderError(f"missing config value `{key}`")
|
||||
if len(matches) > 1:
|
||||
paths = ", ".join(path for path, _ in matches)
|
||||
raise RenderError(f"ambiguous config value `{key}` found at: {paths}")
|
||||
path, value = matches[0]
|
||||
return path, _resolve_config_value(value, f"config.{path}", project_root)
|
||||
|
||||
|
||||
def _format_markdown_list(items: list[str]) -> str:
|
||||
if not items:
|
||||
return "_None._"
|
||||
rendered = []
|
||||
for item in items:
|
||||
lines = item.splitlines() or [""]
|
||||
rendered.append("- " + lines[0])
|
||||
rendered.extend(" " + line for line in lines[1:])
|
||||
return "\n".join(rendered)
|
||||
|
||||
|
||||
def _format_review_layers(layers: list[dict[str, str]]) -> str:
|
||||
active = [layer for layer in layers if layer["instruction"].strip()]
|
||||
if not active:
|
||||
return "No active review layers. HALT with blocking condition `no active review layers`."
|
||||
sections = []
|
||||
for layer in active:
|
||||
section = [f"#### {layer['name']} (`{layer['id']}`)"]
|
||||
if layer.get("when"):
|
||||
section.extend(["", f"Run only when: {layer['when']}"])
|
||||
section.extend(["", layer["instruction"].strip()])
|
||||
sections.append("\n".join(section))
|
||||
return "\n\n".join(sections)
|
||||
|
||||
|
||||
def _resolve_customization_value(value: Any, default: Any, label: str) -> tuple[Any, str]:
|
||||
if isinstance(default, str):
|
||||
allow_empty = not default.strip() or label == "customization.workflow.open_spec"
|
||||
resolved = _require_string(value, label, allow_empty=allow_empty)
|
||||
return resolved, resolved
|
||||
if isinstance(default, list):
|
||||
if default and all(isinstance(item, dict) for item in default):
|
||||
resolved = _require_review_layers(value, label)
|
||||
return resolved, _format_review_layers(resolved)
|
||||
resolved = _require_string_list(value, label)
|
||||
return resolved, _format_markdown_list(resolved)
|
||||
raise RenderError(f"{label} has unsupported default type {type(default).__name__}")
|
||||
|
||||
|
||||
def _resolve_replacements(
|
||||
sources: dict[str, str],
|
||||
central: dict[str, Any],
|
||||
customization: dict[str, Any],
|
||||
defaults: dict[str, Any] | None,
|
||||
project_root: Path,
|
||||
) -> tuple[dict[str, str], dict[str, Any]]:
|
||||
replacements: dict[str, str] = {}
|
||||
input_values: dict[str, Any] = {}
|
||||
for content in sources.values():
|
||||
for match in _SHORT_CONFIG_TOKEN.finditer(content):
|
||||
token, key = match.group(0), match.group(1)
|
||||
path, resolved = _resolve_short_config(central, key, project_root)
|
||||
source = f"config.{path}"
|
||||
replacements[token] = resolved
|
||||
input_values[source] = resolved
|
||||
for match in _CONFIG_TOKEN.finditer(content):
|
||||
token, path = match.group(0), match.group(1)
|
||||
source = f"config.{path}"
|
||||
resolved = _resolve_config_value(
|
||||
_lookup(central, path, "config value"), source, project_root
|
||||
)
|
||||
replacements[token] = resolved
|
||||
input_values[source] = resolved
|
||||
for match in _CUSTOM_TOKEN.finditer(content):
|
||||
if defaults is None:
|
||||
raise RenderError("customization tokens require customize.toml")
|
||||
token, relative_path = match.group(0), match.group(1)
|
||||
path = f"workflow.{relative_path}"
|
||||
source = f"customization.{path}"
|
||||
resolved, rendered = _resolve_customization_value(
|
||||
_lookup(customization, path, "customization value"),
|
||||
_lookup(defaults, path, "customization default"),
|
||||
source,
|
||||
)
|
||||
replacements[token] = rendered
|
||||
input_values[source] = resolved
|
||||
return replacements, input_values
|
||||
|
||||
|
||||
def _render_sources(
|
||||
sources: dict[str, str], replacements: dict[str, str], destination: Path
|
||||
) -> dict[str, str]:
|
||||
"""Resolve only tokens authored in installed sources in one opaque pass."""
|
||||
# Workflow customization may reference installed skill files; bind those
|
||||
# references to the immutable generation before inserting the prose.
|
||||
replacements = {
|
||||
token: value.replace("{skill-root}", str(destination))
|
||||
if token.startswith("{workflow.")
|
||||
else value
|
||||
for token, value in replacements.items()
|
||||
}
|
||||
source_names = set(sources)
|
||||
patterns = [
|
||||
*(re.escape(token) for token in sorted(replacements, key=len, reverse=True)),
|
||||
_SNAPSHOT_TOKEN.pattern,
|
||||
]
|
||||
token_pattern = re.compile("|".join(patterns))
|
||||
|
||||
def replace(match: re.Match[str]) -> str:
|
||||
token = match.group(0)
|
||||
if token in replacements:
|
||||
return replacements[token]
|
||||
snapshot = _SNAPSHOT_TOKEN.fullmatch(token)
|
||||
if snapshot is None:
|
||||
raise RenderError(f"unsupported render token: {token}")
|
||||
target = snapshot.group(1)
|
||||
if target not in source_names:
|
||||
raise RenderError(f"snapshot reference targets undeclared source: {target}")
|
||||
return str(destination / target)
|
||||
|
||||
rendered: dict[str, str] = {}
|
||||
for name, content in sources.items():
|
||||
# Inserted paths and customization prose are never scanned as source tokens.
|
||||
rendered[name] = token_pattern.sub(replace, content)
|
||||
return rendered
|
||||
|
||||
|
||||
def _verify_existing(destination: Path, manifest: dict[str, Any]) -> None:
|
||||
manifest_path = destination / "manifest.json"
|
||||
try:
|
||||
existing = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as error:
|
||||
raise RenderError(f"corrupt existing generation {destination}: {error}") from error
|
||||
if existing != manifest:
|
||||
raise RenderError(f"generation collision or corruption at {destination}")
|
||||
expected_files = set(manifest["outputs"]) | {"manifest.json"}
|
||||
actual_files = {
|
||||
path.relative_to(destination).as_posix()
|
||||
for path in destination.rglob("*")
|
||||
if path.is_file()
|
||||
}
|
||||
if actual_files != expected_files:
|
||||
raise RenderError(f"generation contains unexpected or missing files: {destination}")
|
||||
for name, expected_hash in manifest["outputs"].items():
|
||||
try:
|
||||
actual_hash = _hash_bytes((destination / name).read_bytes())
|
||||
except OSError as error:
|
||||
raise RenderError(f"failed to verify {destination / name}: {error}") from error
|
||||
if actual_hash != expected_hash:
|
||||
raise RenderError(f"generation output hash mismatch: {destination / name}")
|
||||
|
||||
|
||||
def _publish(destination: Path, outputs: dict[str, bytes], manifest: dict[str, Any]) -> None:
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
if destination.exists():
|
||||
_verify_existing(destination, manifest)
|
||||
return
|
||||
staging = Path(tempfile.mkdtemp(prefix=".staging-", dir=destination.parent))
|
||||
try:
|
||||
for name, content in outputs.items():
|
||||
path = staging / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(content)
|
||||
(staging / "manifest.json").write_bytes(
|
||||
json.dumps(manifest, ensure_ascii=False, indent=2, sort_keys=True).encode("utf-8")
|
||||
+ b"\n"
|
||||
)
|
||||
try:
|
||||
os.rename(staging, destination)
|
||||
except OSError:
|
||||
if destination.exists():
|
||||
_verify_existing(destination, manifest)
|
||||
else:
|
||||
raise
|
||||
finally:
|
||||
if staging.exists():
|
||||
shutil.rmtree(staging, ignore_errors=True)
|
||||
|
||||
|
||||
def render(project_root: Path, skill_dir: Path) -> Path:
|
||||
project_root = project_root.resolve(strict=True)
|
||||
skill_dir = skill_dir.resolve(strict=True)
|
||||
if not (project_root / "_bmad").is_dir():
|
||||
raise RenderError(f"project root does not contain _bmad/: {project_root}")
|
||||
|
||||
sources = _load_sources(skill_dir)
|
||||
central = load_central_config(project_root)
|
||||
has_customization = any(
|
||||
_CUSTOM_TOKEN.search(content) for content in sources.values()
|
||||
)
|
||||
defaults = (
|
||||
load_toml(skill_dir / "customize.toml", required=True)
|
||||
if has_customization
|
||||
else None
|
||||
)
|
||||
customization = (
|
||||
load_customization(project_root, skill_dir) if has_customization else {}
|
||||
)
|
||||
replacements, input_values = _resolve_replacements(
|
||||
sources, central, customization, defaults, project_root
|
||||
)
|
||||
source_hashes = {
|
||||
name: _hash_bytes(content.encode("utf-8")) for name, content in sources.items()
|
||||
}
|
||||
root_hash = _hash_bytes(str(project_root).encode("utf-8"))[:12]
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", project_root.name.lower()).strip("-") or "project"
|
||||
slug = slug[:80].rstrip("-") or "project"
|
||||
renderer_hash = _hash_bytes(Path(__file__).read_bytes())
|
||||
identity = {
|
||||
"project_root": str(project_root),
|
||||
"renderer_sha256": renderer_hash,
|
||||
"resolved_values": input_values,
|
||||
"source_sha256": source_hashes,
|
||||
}
|
||||
generation_hash = _hash_bytes(_canonical_json(identity))[:20]
|
||||
destination = (
|
||||
project_root
|
||||
/ "_bmad"
|
||||
/ "render"
|
||||
/ skill_dir.name
|
||||
/ f"{slug}-{root_hash}"
|
||||
/ generation_hash
|
||||
)
|
||||
rendered = _render_sources(sources, replacements, destination)
|
||||
outputs = {name: content.encode("utf-8") for name, content in rendered.items()}
|
||||
output_hashes = {name: _hash_bytes(content) for name, content in outputs.items()}
|
||||
manifest = {
|
||||
"schema_version": 1,
|
||||
"skill": skill_dir.name,
|
||||
"project_root": str(project_root),
|
||||
"project_slug": slug,
|
||||
"root_hash": root_hash,
|
||||
"generation_hash": generation_hash,
|
||||
"inputs": identity,
|
||||
"outputs": output_hashes,
|
||||
}
|
||||
_publish(destination, outputs, manifest)
|
||||
return destination / "workflow.md"
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--project-root", required=True)
|
||||
parser.add_argument("--skill", required=True)
|
||||
args = parser.parse_args()
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(encoding="utf-8")
|
||||
try:
|
||||
entry = render(Path(args.project_root), Path(args.skill))
|
||||
except (ConfigError, RenderError, OSError, UnicodeError, ValueError) as error:
|
||||
sys.stdout.write(f"HALT: {error}\n")
|
||||
return 1
|
||||
sys.stdout.write(f"read and follow {entry}\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,74 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Resolve BMad's four central TOML layers to JSON."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Installed scripts are consumer files, not a location for interpreter caches.
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
try:
|
||||
from config_utils import ConfigError, load_central_config
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "tomllib":
|
||||
raise
|
||||
sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n")
|
||||
raise SystemExit(3) from None
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def extract_key(data, dotted_key: str):
|
||||
current = data
|
||||
for part in dotted_key.split("."):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return _MISSING
|
||||
return current
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Resolve BMad central config using four-layer TOML merge."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
"-p",
|
||||
required=True,
|
||||
help="Absolute project root containing _bmad/",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
"-k",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Dotted field path to resolve (repeatable). Omit for full dump.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
try:
|
||||
merged = load_central_config(Path(args.project_root).resolve())
|
||||
except ConfigError as error:
|
||||
sys.stderr.write(f"error: {error}\n")
|
||||
return 1
|
||||
|
||||
output = merged
|
||||
if args.key:
|
||||
output = {}
|
||||
for key in args.key:
|
||||
value = extract_key(merged, key)
|
||||
if value is not _MISSING:
|
||||
output[key] = value
|
||||
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Executable
+99
@@ -0,0 +1,99 @@
|
||||
#!/usr/bin/env python3
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# ///
|
||||
"""Resolve a skill's default, team, and user TOML customization layers."""
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
# Installed scripts are consumer files, not a location for interpreter caches.
|
||||
sys.dont_write_bytecode = True
|
||||
|
||||
try:
|
||||
from config_utils import ConfigError, load_customization
|
||||
except ModuleNotFoundError as error:
|
||||
if error.name != "tomllib":
|
||||
raise
|
||||
sys.stderr.write("error: Python 3.11+ is required (stdlib `tomllib` not found).\n")
|
||||
raise SystemExit(3) from None
|
||||
|
||||
|
||||
_MISSING = object()
|
||||
|
||||
|
||||
def find_project_root(start: Path) -> Path | None:
|
||||
current = start.resolve()
|
||||
while True:
|
||||
if (current / "_bmad").exists() or (current / ".git").exists():
|
||||
return current
|
||||
if current.parent == current:
|
||||
return None
|
||||
current = current.parent
|
||||
|
||||
|
||||
def extract_key(data, dotted_key: str):
|
||||
current = data
|
||||
for part in dotted_key.split("."):
|
||||
if isinstance(current, dict) and part in current:
|
||||
current = current[part]
|
||||
else:
|
||||
return _MISSING
|
||||
return current
|
||||
|
||||
|
||||
def write_json_stdout(output) -> None:
|
||||
reconfigure = getattr(sys.stdout, "reconfigure", None)
|
||||
if reconfigure is not None:
|
||||
reconfigure(encoding="utf-8")
|
||||
sys.stdout.write(json.dumps(output, indent=2, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Resolve skill customization using three-layer TOML merge."
|
||||
)
|
||||
parser.add_argument(
|
||||
"--skill", "-s", required=True, help="Absolute path to the skill directory"
|
||||
)
|
||||
parser.add_argument(
|
||||
"--project-root",
|
||||
"-p",
|
||||
help="Explicit project root containing _bmad/ (recommended)",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--key",
|
||||
"-k",
|
||||
action="append",
|
||||
default=[],
|
||||
help="Dotted field path to resolve (repeatable). Omit for full dump.",
|
||||
)
|
||||
args = parser.parse_args()
|
||||
|
||||
skill_dir = Path(args.skill).resolve()
|
||||
project_root = (
|
||||
Path(args.project_root).resolve()
|
||||
if args.project_root
|
||||
else find_project_root(skill_dir) or find_project_root(Path.cwd())
|
||||
)
|
||||
try:
|
||||
merged = load_customization(project_root, skill_dir)
|
||||
except ConfigError as error:
|
||||
sys.stderr.write(f"error: {error}\n")
|
||||
return 1
|
||||
|
||||
output = merged
|
||||
if args.key:
|
||||
output = {}
|
||||
for key in args.key:
|
||||
value = extract_key(merged, key)
|
||||
if value is not _MISSING:
|
||||
output[key] = value
|
||||
write_json_stdout(output)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user