diff --git a/skills/bmad-agent-builder/SKILL.md b/skills/bmad-agent-builder/SKILL.md index 9a79282..fca1fba 100644 --- a/skills/bmad-agent-builder/SKILL.md +++ b/skills/bmad-agent-builder/SKILL.md @@ -3,6 +3,19 @@ name: bmad-agent-builder description: Builds, edits or analyzes Agent Skills through conversational discovery. Use when the user requests to "Create an Agent", "Analyze an Agent" or "Edit an Agent". --- +## Available Scripts + +- **`scripts/resolve-customization.py`** -- Resolves customization from three-layer TOML merge (user > team > defaults). Outputs JSON. + +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python3 scripts/resolve-customization.py bmad-agent-builder --key inject --key additional_resources` +Use the JSON output as resolved values. + +1. **Inject before** -- If `inject.before` resolved to a non-empty value, prepend it to your active instructions and follow it. +2. **Available resources** -- Note the `additional_resources` list. Do not read these files now; they are available for the injected prompt or workflow steps to reference when needed. + # Agent Builder ## Overview @@ -68,3 +81,10 @@ Load `./references/quality-analysis.md` to begin. Analyze routes to `./references/quality-analysis.md`. Edit routes to `./references/edit-guidance.md`. Rebuild routes to `./references/build-process.md` with the chosen intent. Regardless of path, respect headless mode if requested. + +## Post-Workflow Customization + +After the workflow completes, resolve `inject.after` from customization: +Run: `python3 scripts/resolve-customization.py bmad-agent-builder --key inject.after` + +If resolved `inject.after` is not empty, append it to your active instructions and follow it. diff --git a/skills/bmad-agent-builder/customize.toml b/skills/bmad-agent-builder/customize.toml new file mode 100644 index 0000000..85e2798 --- /dev/null +++ b/skills/bmad-agent-builder/customize.toml @@ -0,0 +1,27 @@ +# ────────────────────────────────────────────────────────────────── +# Customization Defaults: bmad-agent-builder +# This file defines all customizable fields for this skill. +# DO NOT EDIT THIS FILE -- it is overwritten on every update. +# +# HOW TO CUSTOMIZE: +# 1. Create an override file with only the fields you want to change: +# _bmad/customizations/bmad-agent-builder.toml (team/org, committed to git) +# _bmad/customizations/bmad-agent-builder.user.toml (personal, gitignored) +# 2. Copy just the fields you want to override into your file. +# Unmentioned fields inherit from this defaults file. +# 3. For array fields (like additional_resources), include the +# complete array you want -- arrays replace, not append. +# ────────────────────────────────────────────────────────────────── + +# Additional resource files loaded into workflow context on activation. +# Paths are relative to {project-root}. +additional_resources = [] + +# ────────────────────────────────────────────────────────────────── +# Injected prompts - content woven into the workflow's context. +# 'before' loads before the workflow begins. +# 'after' loads after the workflow completes (pre-finalize). +# ────────────────────────────────────────────────────────────────── +[inject] +before = "" +after = "" diff --git a/skills/bmad-agent-builder/scripts/resolve-customization.py b/skills/bmad-agent-builder/scripts/resolve-customization.py new file mode 100755 index 0000000..d9994a5 --- /dev/null +++ b/skills/bmad-agent-builder/scripts/resolve-customization.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError) as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* is a non-empty list where ALL items are dicts with a ``code`` key.""" + return ( + isinstance(value, list) + and len(value) > 0 + and all(isinstance(item, dict) and "code" in item for item in value) + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base if "code" in item} + for item in override: + if "code" not in item: + print(f"warning: menu item missing 'code' key, skipping: {item}", file=sys.stderr) + continue + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type] + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/skills/bmad-bmb-setup/SKILL.md b/skills/bmad-bmb-setup/SKILL.md index 80f6cdf..a9ad4ab 100644 --- a/skills/bmad-bmb-setup/SKILL.md +++ b/skills/bmad-bmb-setup/SKILL.md @@ -3,6 +3,19 @@ name: bmad-bmb-setup description: Sets up BMad Builder module in a project. Use when the user requests to 'install bmb module', 'configure BMad Builder', or 'setup BMad Builder'. --- +## Available Scripts + +- **`scripts/resolve-customization.py`** -- Resolves customization from three-layer TOML merge (user > team > defaults). Outputs JSON. + +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python3 scripts/resolve-customization.py bmad-bmb-setup --key inject --key additional_resources` +Use the JSON output as resolved values. + +1. **Inject before** -- If `inject.before` resolved to a non-empty value, prepend it to your active instructions and follow it. +2. **Available resources** -- Note the `additional_resources` list. Do not read these files now; they are available for the injected prompt or workflow steps to reference when needed. + # Module Setup ## Overview @@ -74,3 +87,10 @@ Use the script JSON output to display what was written — config values set (wr ## Outcome Once the user's `user_name` and `communication_language` are known (from collected input, arguments, or existing config), use them consistently for the remainder of the session: address the user by their configured name and communicate in their configured `communication_language`. + +## Post-Workflow Customization + +After the workflow completes, resolve `inject.after` from customization: +Run: `python3 scripts/resolve-customization.py bmad-bmb-setup --key inject.after` + +If resolved `inject.after` is not empty, append it to your active instructions and follow it. diff --git a/skills/bmad-bmb-setup/customize.toml b/skills/bmad-bmb-setup/customize.toml new file mode 100644 index 0000000..6082bf8 --- /dev/null +++ b/skills/bmad-bmb-setup/customize.toml @@ -0,0 +1,27 @@ +# ────────────────────────────────────────────────────────────────── +# Customization Defaults: bmad-bmb-setup +# This file defines all customizable fields for this skill. +# DO NOT EDIT THIS FILE -- it is overwritten on every update. +# +# HOW TO CUSTOMIZE: +# 1. Create an override file with only the fields you want to change: +# _bmad/customizations/bmad-bmb-setup.toml (team/org, committed to git) +# _bmad/customizations/bmad-bmb-setup.user.toml (personal, gitignored) +# 2. Copy just the fields you want to override into your file. +# Unmentioned fields inherit from this defaults file. +# 3. For array fields (like additional_resources), include the +# complete array you want -- arrays replace, not append. +# ────────────────────────────────────────────────────────────────── + +# Additional resource files loaded into workflow context on activation. +# Paths are relative to {project-root}. +additional_resources = [] + +# ────────────────────────────────────────────────────────────────── +# Injected prompts - content woven into the workflow's context. +# 'before' loads before the workflow begins. +# 'after' loads after the workflow completes (pre-finalize). +# ────────────────────────────────────────────────────────────────── +[inject] +before = "" +after = "" diff --git a/skills/bmad-bmb-setup/scripts/resolve-customization.py b/skills/bmad-bmb-setup/scripts/resolve-customization.py new file mode 100755 index 0000000..d9994a5 --- /dev/null +++ b/skills/bmad-bmb-setup/scripts/resolve-customization.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError) as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* is a non-empty list where ALL items are dicts with a ``code`` key.""" + return ( + isinstance(value, list) + and len(value) > 0 + and all(isinstance(item, dict) and "code" in item for item in value) + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base if "code" in item} + for item in override: + if "code" not in item: + print(f"warning: menu item missing 'code' key, skipping: {item}", file=sys.stderr) + continue + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type] + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/skills/bmad-module-builder/SKILL.md b/skills/bmad-module-builder/SKILL.md index b735e6c..231a7c3 100644 --- a/skills/bmad-module-builder/SKILL.md +++ b/skills/bmad-module-builder/SKILL.md @@ -3,6 +3,19 @@ name: bmad-module-builder description: Plans, creates, and validates BMad modules. Use when the user requests to 'ideate module', 'plan a module', 'create module', 'build a module', or 'validate module'. --- +## Available Scripts + +- **`scripts/resolve-customization.py`** -- Resolves customization from three-layer TOML merge (user > team > defaults). Outputs JSON. + +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python3 scripts/resolve-customization.py bmad-module-builder --key inject --key additional_resources` +Use the JSON output as resolved values. + +1. **Inject before** -- If `inject.before` resolved to a non-empty value, prepend it to your active instructions and follow it. +2. **Available resources** -- Note the `additional_resources` list. Do not read these files now; they are available for the injected prompt or workflow steps to reference when needed. + # BMad Module Builder ## Overview @@ -30,3 +43,10 @@ Detect user's intent: - **Validate Module (VM)** — "I want to check that my module's setup skill is complete and correct" If `--headless` or `-H` is passed, route to CM with headless mode. + +## Post-Workflow Customization + +After the workflow completes, resolve `inject.after` from customization: +Run: `python3 scripts/resolve-customization.py bmad-module-builder --key inject.after` + +If resolved `inject.after` is not empty, append it to your active instructions and follow it. diff --git a/skills/bmad-module-builder/customize.toml b/skills/bmad-module-builder/customize.toml new file mode 100644 index 0000000..fcea5a0 --- /dev/null +++ b/skills/bmad-module-builder/customize.toml @@ -0,0 +1,27 @@ +# ────────────────────────────────────────────────────────────────── +# Customization Defaults: bmad-module-builder +# This file defines all customizable fields for this skill. +# DO NOT EDIT THIS FILE -- it is overwritten on every update. +# +# HOW TO CUSTOMIZE: +# 1. Create an override file with only the fields you want to change: +# _bmad/customizations/bmad-module-builder.toml (team/org, committed to git) +# _bmad/customizations/bmad-module-builder.user.toml (personal, gitignored) +# 2. Copy just the fields you want to override into your file. +# Unmentioned fields inherit from this defaults file. +# 3. For array fields (like additional_resources), include the +# complete array you want -- arrays replace, not append. +# ────────────────────────────────────────────────────────────────── + +# Additional resource files loaded into workflow context on activation. +# Paths are relative to {project-root}. +additional_resources = [] + +# ────────────────────────────────────────────────────────────────── +# Injected prompts - content woven into the workflow's context. +# 'before' loads before the workflow begins. +# 'after' loads after the workflow completes (pre-finalize). +# ────────────────────────────────────────────────────────────────── +[inject] +before = "" +after = "" diff --git a/skills/bmad-module-builder/scripts/resolve-customization.py b/skills/bmad-module-builder/scripts/resolve-customization.py new file mode 100755 index 0000000..d9994a5 --- /dev/null +++ b/skills/bmad-module-builder/scripts/resolve-customization.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError) as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* is a non-empty list where ALL items are dicts with a ``code`` key.""" + return ( + isinstance(value, list) + and len(value) > 0 + and all(isinstance(item, dict) and "code" in item for item in value) + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base if "code" in item} + for item in override: + if "code" not in item: + print(f"warning: menu item missing 'code' key, skipping: {item}", file=sys.stderr) + continue + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type] + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/skills/bmad-workflow-builder/SKILL.md b/skills/bmad-workflow-builder/SKILL.md index 0ce1fc4..2023860 100644 --- a/skills/bmad-workflow-builder/SKILL.md +++ b/skills/bmad-workflow-builder/SKILL.md @@ -3,6 +3,19 @@ name: bmad-workflow-builder description: Builds, converts, and analyzes workflows and skills. Use when the user requests to "build a workflow", "modify a workflow", "quality check workflow", "analyze skill", or "convert a skill". --- +## Available Scripts + +- **`scripts/resolve-customization.py`** -- Resolves customization from three-layer TOML merge (user > team > defaults). Outputs JSON. + +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python3 scripts/resolve-customization.py bmad-workflow-builder --key inject --key additional_resources` +Use the JSON output as resolved values. + +1. **Inject before** -- If `inject.before` resolved to a non-empty value, prepend it to your active instructions and follow it. +2. **Available resources** -- Note the `additional_resources` list. Do not read these files now; they are available for the injected prompt or workflow steps to reference when needed. + # Workflow & Skill Builder ## Overview @@ -69,3 +82,10 @@ Load `references/convert-process.md` to begin. Analyze routes to `references/quality-analysis.md`. Edit and Rebuild both route to `references/build-process.md` with the chosen intent. Regardless of path, respect headless mode if requested. + +## Post-Workflow Customization + +After the workflow completes, resolve `inject.after` from customization: +Run: `python3 scripts/resolve-customization.py bmad-workflow-builder --key inject.after` + +If resolved `inject.after` is not empty, append it to your active instructions and follow it. diff --git a/skills/bmad-workflow-builder/customize.toml b/skills/bmad-workflow-builder/customize.toml new file mode 100644 index 0000000..7e9c398 --- /dev/null +++ b/skills/bmad-workflow-builder/customize.toml @@ -0,0 +1,27 @@ +# ────────────────────────────────────────────────────────────────── +# Customization Defaults: bmad-workflow-builder +# This file defines all customizable fields for this skill. +# DO NOT EDIT THIS FILE -- it is overwritten on every update. +# +# HOW TO CUSTOMIZE: +# 1. Create an override file with only the fields you want to change: +# _bmad/customizations/bmad-workflow-builder.toml (team/org, committed to git) +# _bmad/customizations/bmad-workflow-builder.user.toml (personal, gitignored) +# 2. Copy just the fields you want to override into your file. +# Unmentioned fields inherit from this defaults file. +# 3. For array fields (like additional_resources), include the +# complete array you want -- arrays replace, not append. +# ────────────────────────────────────────────────────────────────── + +# Additional resource files loaded into workflow context on activation. +# Paths are relative to {project-root}. +additional_resources = [] + +# ────────────────────────────────────────────────────────────────── +# Injected prompts - content woven into the workflow's context. +# 'before' loads before the workflow begins. +# 'after' loads after the workflow completes (pre-finalize). +# ────────────────────────────────────────────────────────────────── +[inject] +before = "" +after = "" diff --git a/skills/bmad-workflow-builder/scripts/resolve-customization.py b/skills/bmad-workflow-builder/scripts/resolve-customization.py new file mode 100755 index 0000000..d9994a5 --- /dev/null +++ b/skills/bmad-workflow-builder/scripts/resolve-customization.py @@ -0,0 +1,183 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except (tomllib.TOMLDecodeError, OSError) as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* is a non-empty list where ALL items are dicts with a ``code`` key.""" + return ( + isinstance(value, list) + and len(value) > 0 + and all(isinstance(item, dict) and "code" in item for item in value) + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base if "code" in item} + for item in override: + if "code" not in item: + print(f"warning: menu item missing 'code' key, skipping: {item}", file=sys.stderr) + continue + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type] + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main()