From b4a48940048816b8d32e51ad0944fa97c4ee057a Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Tue, 14 Apr 2026 11:03:33 -0500 Subject: [PATCH 1/5] feat(skills): add TOML-based skill customization system Add customize.toml with stock customization fields (inject before/after, additional_resources) to all 4 builder skills. Include resolve-customization.py script in each skill's scripts/ directory. Add customization resolve and inject points to all SKILL.md files. --- skills/bmad-agent-builder/SKILL.md | 16 ++ skills/bmad-agent-builder/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ skills/bmad-bmb-setup/SKILL.md | 16 ++ skills/bmad-bmb-setup/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ skills/bmad-module-builder/SKILL.md | 16 ++ skills/bmad-module-builder/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ skills/bmad-workflow-builder/SKILL.md | 16 ++ skills/bmad-workflow-builder/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ 12 files changed, 900 insertions(+) create mode 100644 skills/bmad-agent-builder/customize.toml create mode 100755 skills/bmad-agent-builder/scripts/resolve-customization.py create mode 100644 skills/bmad-bmb-setup/customize.toml create mode 100755 skills/bmad-bmb-setup/scripts/resolve-customization.py create mode 100644 skills/bmad-module-builder/customize.toml create mode 100755 skills/bmad-module-builder/scripts/resolve-customization.py create mode 100644 skills/bmad-workflow-builder/customize.toml create mode 100755 skills/bmad-workflow-builder/scripts/resolve-customization.py diff --git a/skills/bmad-agent-builder/SKILL.md b/skills/bmad-agent-builder/SKILL.md index 9a79282..0bac021 100644 --- a/skills/bmad-agent-builder/SKILL.md +++ b/skills/bmad-agent-builder/SKILL.md @@ -3,6 +3,15 @@ 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". --- +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python ./scripts/resolve-customization.py bmad-agent-builder --key inject --key additional_resources` +Use the JSON output as resolved values. + +If `inject.before` is not empty, incorporate its content as high-priority context. +If `additional_resources` is not empty, read each listed file and incorporate as reference context. + # Agent Builder ## Overview @@ -68,3 +77,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: `python ./scripts/resolve-customization.py bmad-agent-builder --key inject.after` + +If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. 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..b4c6aae --- /dev/null +++ b/skills/bmad-agent-builder/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/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 os +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 Exception 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* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +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} + for item in override: + 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) + 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..1ffea16 100644 --- a/skills/bmad-bmb-setup/SKILL.md +++ b/skills/bmad-bmb-setup/SKILL.md @@ -3,6 +3,15 @@ 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'. --- +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python ./scripts/resolve-customization.py bmad-bmb-setup --key inject --key additional_resources` +Use the JSON output as resolved values. + +If `inject.before` is not empty, incorporate its content as high-priority context. +If `additional_resources` is not empty, read each listed file and incorporate as reference context. + # Module Setup ## Overview @@ -74,3 +83,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: `python ./scripts/resolve-customization.py bmad-bmb-setup --key inject.after` + +If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. 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..b4c6aae --- /dev/null +++ b/skills/bmad-bmb-setup/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/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 os +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 Exception 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* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +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} + for item in override: + 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) + 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..654c9db 100644 --- a/skills/bmad-module-builder/SKILL.md +++ b/skills/bmad-module-builder/SKILL.md @@ -3,6 +3,15 @@ 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'. --- +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python ./scripts/resolve-customization.py bmad-module-builder --key inject --key additional_resources` +Use the JSON output as resolved values. + +If `inject.before` is not empty, incorporate its content as high-priority context. +If `additional_resources` is not empty, read each listed file and incorporate as reference context. + # BMad Module Builder ## Overview @@ -30,3 +39,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: `python ./scripts/resolve-customization.py bmad-module-builder --key inject.after` + +If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. 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..b4c6aae --- /dev/null +++ b/skills/bmad-module-builder/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/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 os +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 Exception 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* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +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} + for item in override: + 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) + 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..05df229 100644 --- a/skills/bmad-workflow-builder/SKILL.md +++ b/skills/bmad-workflow-builder/SKILL.md @@ -3,6 +3,15 @@ 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". --- +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python ./scripts/resolve-customization.py bmad-workflow-builder --key inject --key additional_resources` +Use the JSON output as resolved values. + +If `inject.before` is not empty, incorporate its content as high-priority context. +If `additional_resources` is not empty, read each listed file and incorporate as reference context. + # Workflow & Skill Builder ## Overview @@ -69,3 +78,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: `python ./scripts/resolve-customization.py bmad-workflow-builder --key inject.after` + +If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. 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..b4c6aae --- /dev/null +++ b/skills/bmad-workflow-builder/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/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 os +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 Exception 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* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +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} + for item in override: + 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) + 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() From 956fd641ad0ba307f8860f36afe1e9c8beeb8861 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Tue, 14 Apr 2026 12:04:33 -0500 Subject: [PATCH 2/5] fix: narrow exception handler in resolve-customization.py Change except Exception to except (tomllib.TOMLDecodeError, OSError) for clearer failure signaling in user-facing tooling. --- skills/bmad-agent-builder/scripts/resolve-customization.py | 2 +- skills/bmad-bmb-setup/scripts/resolve-customization.py | 2 +- skills/bmad-module-builder/scripts/resolve-customization.py | 2 +- skills/bmad-workflow-builder/scripts/resolve-customization.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/skills/bmad-agent-builder/scripts/resolve-customization.py b/skills/bmad-agent-builder/scripts/resolve-customization.py index b4c6aae..6bbce66 100755 --- a/skills/bmad-agent-builder/scripts/resolve-customization.py +++ b/skills/bmad-agent-builder/scripts/resolve-customization.py @@ -47,7 +47,7 @@ def load_toml(path: Path) -> dict[str, Any]: try: with open(path, "rb") as f: return tomllib.load(f) - except Exception as exc: + except (tomllib.TOMLDecodeError, OSError) as exc: print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) return {} diff --git a/skills/bmad-bmb-setup/scripts/resolve-customization.py b/skills/bmad-bmb-setup/scripts/resolve-customization.py index b4c6aae..6bbce66 100755 --- a/skills/bmad-bmb-setup/scripts/resolve-customization.py +++ b/skills/bmad-bmb-setup/scripts/resolve-customization.py @@ -47,7 +47,7 @@ def load_toml(path: Path) -> dict[str, Any]: try: with open(path, "rb") as f: return tomllib.load(f) - except Exception as exc: + except (tomllib.TOMLDecodeError, OSError) as exc: print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) return {} diff --git a/skills/bmad-module-builder/scripts/resolve-customization.py b/skills/bmad-module-builder/scripts/resolve-customization.py index b4c6aae..6bbce66 100755 --- a/skills/bmad-module-builder/scripts/resolve-customization.py +++ b/skills/bmad-module-builder/scripts/resolve-customization.py @@ -47,7 +47,7 @@ def load_toml(path: Path) -> dict[str, Any]: try: with open(path, "rb") as f: return tomllib.load(f) - except Exception as exc: + except (tomllib.TOMLDecodeError, OSError) as exc: print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) return {} diff --git a/skills/bmad-workflow-builder/scripts/resolve-customization.py b/skills/bmad-workflow-builder/scripts/resolve-customization.py index b4c6aae..6bbce66 100755 --- a/skills/bmad-workflow-builder/scripts/resolve-customization.py +++ b/skills/bmad-workflow-builder/scripts/resolve-customization.py @@ -47,7 +47,7 @@ def load_toml(path: Path) -> dict[str, Any]: try: with open(path, "rb") as f: return tomllib.load(f) - except Exception as exc: + except (tomllib.TOMLDecodeError, OSError) as exc: print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) return {} From 5f7fabae2dd5a3260ca79b4906dd84ade1070282 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Tue, 14 Apr 2026 12:52:05 -0500 Subject: [PATCH 3/5] style(skills): follow agentskills.io script conventions - Drop ./ prefix from script paths (use scripts/ not ./scripts/) - Use python3 instead of python for explicitness - Add Available Scripts listing to all SKILL.md files --- skills/bmad-agent-builder/SKILL.md | 8 ++++++-- skills/bmad-bmb-setup/SKILL.md | 8 ++++++-- skills/bmad-module-builder/SKILL.md | 8 ++++++-- skills/bmad-workflow-builder/SKILL.md | 8 ++++++-- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/skills/bmad-agent-builder/SKILL.md b/skills/bmad-agent-builder/SKILL.md index 0bac021..c2f0d30 100644 --- a/skills/bmad-agent-builder/SKILL.md +++ b/skills/bmad-agent-builder/SKILL.md @@ -3,10 +3,14 @@ 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: `python ./scripts/resolve-customization.py bmad-agent-builder --key inject --key additional_resources` +Run: `python3 scripts/resolve-customization.py bmad-agent-builder --key inject --key additional_resources` Use the JSON output as resolved values. If `inject.before` is not empty, incorporate its content as high-priority context. @@ -81,6 +85,6 @@ Regardless of path, respect headless mode if requested. ## Post-Workflow Customization After the workflow completes, resolve `inject.after` from customization: -Run: `python ./scripts/resolve-customization.py bmad-agent-builder --key inject.after` +Run: `python3 scripts/resolve-customization.py bmad-agent-builder --key inject.after` If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. diff --git a/skills/bmad-bmb-setup/SKILL.md b/skills/bmad-bmb-setup/SKILL.md index 1ffea16..96ed7bd 100644 --- a/skills/bmad-bmb-setup/SKILL.md +++ b/skills/bmad-bmb-setup/SKILL.md @@ -3,10 +3,14 @@ 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: `python ./scripts/resolve-customization.py bmad-bmb-setup --key inject --key additional_resources` +Run: `python3 scripts/resolve-customization.py bmad-bmb-setup --key inject --key additional_resources` Use the JSON output as resolved values. If `inject.before` is not empty, incorporate its content as high-priority context. @@ -87,6 +91,6 @@ Once the user's `user_name` and `communication_language` are known (from collect ## Post-Workflow Customization After the workflow completes, resolve `inject.after` from customization: -Run: `python ./scripts/resolve-customization.py bmad-bmb-setup --key inject.after` +Run: `python3 scripts/resolve-customization.py bmad-bmb-setup --key inject.after` If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. diff --git a/skills/bmad-module-builder/SKILL.md b/skills/bmad-module-builder/SKILL.md index 654c9db..786e6fb 100644 --- a/skills/bmad-module-builder/SKILL.md +++ b/skills/bmad-module-builder/SKILL.md @@ -3,10 +3,14 @@ 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: `python ./scripts/resolve-customization.py bmad-module-builder --key inject --key additional_resources` +Run: `python3 scripts/resolve-customization.py bmad-module-builder --key inject --key additional_resources` Use the JSON output as resolved values. If `inject.before` is not empty, incorporate its content as high-priority context. @@ -43,6 +47,6 @@ 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: `python ./scripts/resolve-customization.py bmad-module-builder --key inject.after` +Run: `python3 scripts/resolve-customization.py bmad-module-builder --key inject.after` If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. diff --git a/skills/bmad-workflow-builder/SKILL.md b/skills/bmad-workflow-builder/SKILL.md index 05df229..7897776 100644 --- a/skills/bmad-workflow-builder/SKILL.md +++ b/skills/bmad-workflow-builder/SKILL.md @@ -3,10 +3,14 @@ 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: `python ./scripts/resolve-customization.py bmad-workflow-builder --key inject --key additional_resources` +Run: `python3 scripts/resolve-customization.py bmad-workflow-builder --key inject --key additional_resources` Use the JSON output as resolved values. If `inject.before` is not empty, incorporate its content as high-priority context. @@ -82,6 +86,6 @@ Regardless of path, respect headless mode if requested. ## Post-Workflow Customization After the workflow completes, resolve `inject.after` from customization: -Run: `python ./scripts/resolve-customization.py bmad-workflow-builder --key inject.after` +Run: `python3 scripts/resolve-customization.py bmad-workflow-builder --key inject.after` If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. From b0d43a7a0bf8acc4f73b5d950531efa41d7636db Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Tue, 14 Apr 2026 13:27:53 -0500 Subject: [PATCH 4/5] fix: harden merge_menu and remove unused import - Fix merge_menu KeyError crash when menu items missing 'code' key - Fix _is_menu_array to check ALL elements, not just first - Remove unused import os from resolve-customization.py --- .../scripts/resolve-customization.py | 11 ++++++----- .../bmad-bmb-setup/scripts/resolve-customization.py | 11 ++++++----- .../scripts/resolve-customization.py | 11 ++++++----- .../scripts/resolve-customization.py | 11 ++++++----- 4 files changed, 24 insertions(+), 20 deletions(-) diff --git a/skills/bmad-agent-builder/scripts/resolve-customization.py b/skills/bmad-agent-builder/scripts/resolve-customization.py index 6bbce66..c102b15 100755 --- a/skills/bmad-agent-builder/scripts/resolve-customization.py +++ b/skills/bmad-agent-builder/scripts/resolve-customization.py @@ -21,7 +21,6 @@ import argparse import json -import os import sys import tomllib from pathlib import Path @@ -57,19 +56,21 @@ def load_toml(path: Path) -> dict[str, Any]: # --------------------------------------------------------------------------- def _is_menu_array(value: Any) -> bool: - """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + """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 isinstance(value[0], dict) - and "code" in 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} + 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()) diff --git a/skills/bmad-bmb-setup/scripts/resolve-customization.py b/skills/bmad-bmb-setup/scripts/resolve-customization.py index 6bbce66..c102b15 100755 --- a/skills/bmad-bmb-setup/scripts/resolve-customization.py +++ b/skills/bmad-bmb-setup/scripts/resolve-customization.py @@ -21,7 +21,6 @@ import argparse import json -import os import sys import tomllib from pathlib import Path @@ -57,19 +56,21 @@ def load_toml(path: Path) -> dict[str, Any]: # --------------------------------------------------------------------------- def _is_menu_array(value: Any) -> bool: - """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + """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 isinstance(value[0], dict) - and "code" in 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} + 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()) diff --git a/skills/bmad-module-builder/scripts/resolve-customization.py b/skills/bmad-module-builder/scripts/resolve-customization.py index 6bbce66..c102b15 100755 --- a/skills/bmad-module-builder/scripts/resolve-customization.py +++ b/skills/bmad-module-builder/scripts/resolve-customization.py @@ -21,7 +21,6 @@ import argparse import json -import os import sys import tomllib from pathlib import Path @@ -57,19 +56,21 @@ def load_toml(path: Path) -> dict[str, Any]: # --------------------------------------------------------------------------- def _is_menu_array(value: Any) -> bool: - """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + """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 isinstance(value[0], dict) - and "code" in 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} + 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()) diff --git a/skills/bmad-workflow-builder/scripts/resolve-customization.py b/skills/bmad-workflow-builder/scripts/resolve-customization.py index 6bbce66..c102b15 100755 --- a/skills/bmad-workflow-builder/scripts/resolve-customization.py +++ b/skills/bmad-workflow-builder/scripts/resolve-customization.py @@ -21,7 +21,6 @@ import argparse import json -import os import sys import tomllib from pathlib import Path @@ -57,19 +56,21 @@ def load_toml(path: Path) -> dict[str, Any]: # --------------------------------------------------------------------------- def _is_menu_array(value: Any) -> bool: - """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + """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 isinstance(value[0], dict) - and "code" in 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} + 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()) From 8bc374d43364576ee7bd3053f980d2e494ce0cf7 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Tue, 14 Apr 2026 16:10:37 -0500 Subject: [PATCH 5/5] fix: harden resolve script type hint + improve inject prompt wording - Add type: ignore[arg-type] to merge_menu call (Pylance narrowing limitation) - Reword inject.before in workflows: "prepend to active instructions and follow it" - Reword inject.after in workflows: "append to active instructions and follow it" - Make additional_resources lazy: note list but don't eagerly load --- skills/bmad-agent-builder/SKILL.md | 6 +++--- skills/bmad-agent-builder/scripts/resolve-customization.py | 2 +- skills/bmad-bmb-setup/SKILL.md | 6 +++--- skills/bmad-bmb-setup/scripts/resolve-customization.py | 2 +- skills/bmad-module-builder/SKILL.md | 6 +++--- skills/bmad-module-builder/scripts/resolve-customization.py | 2 +- skills/bmad-workflow-builder/SKILL.md | 6 +++--- .../bmad-workflow-builder/scripts/resolve-customization.py | 2 +- 8 files changed, 16 insertions(+), 16 deletions(-) diff --git a/skills/bmad-agent-builder/SKILL.md b/skills/bmad-agent-builder/SKILL.md index c2f0d30..fca1fba 100644 --- a/skills/bmad-agent-builder/SKILL.md +++ b/skills/bmad-agent-builder/SKILL.md @@ -13,8 +13,8 @@ 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. -If `inject.before` is not empty, incorporate its content as high-priority context. -If `additional_resources` is not empty, read each listed file and incorporate as reference context. +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 @@ -87,4 +87,4 @@ Regardless of path, respect headless mode if requested. 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, incorporate its content as a final checklist or validation gate. +If resolved `inject.after` is not empty, append it to your active instructions and follow it. diff --git a/skills/bmad-agent-builder/scripts/resolve-customization.py b/skills/bmad-agent-builder/scripts/resolve-customization.py index c102b15..d9994a5 100755 --- a/skills/bmad-agent-builder/scripts/resolve-customization.py +++ b/skills/bmad-agent-builder/scripts/resolve-customization.py @@ -91,7 +91,7 @@ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any] 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) + merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type] else: merged[key] = over_val diff --git a/skills/bmad-bmb-setup/SKILL.md b/skills/bmad-bmb-setup/SKILL.md index 96ed7bd..a9ad4ab 100644 --- a/skills/bmad-bmb-setup/SKILL.md +++ b/skills/bmad-bmb-setup/SKILL.md @@ -13,8 +13,8 @@ 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. -If `inject.before` is not empty, incorporate its content as high-priority context. -If `additional_resources` is not empty, read each listed file and incorporate as reference context. +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 @@ -93,4 +93,4 @@ Once the user's `user_name` and `communication_language` are known (from collect 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, incorporate its content as a final checklist or validation gate. +If resolved `inject.after` is not empty, append it to your active instructions and follow it. diff --git a/skills/bmad-bmb-setup/scripts/resolve-customization.py b/skills/bmad-bmb-setup/scripts/resolve-customization.py index c102b15..d9994a5 100755 --- a/skills/bmad-bmb-setup/scripts/resolve-customization.py +++ b/skills/bmad-bmb-setup/scripts/resolve-customization.py @@ -91,7 +91,7 @@ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any] 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) + merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type] else: merged[key] = over_val diff --git a/skills/bmad-module-builder/SKILL.md b/skills/bmad-module-builder/SKILL.md index 786e6fb..231a7c3 100644 --- a/skills/bmad-module-builder/SKILL.md +++ b/skills/bmad-module-builder/SKILL.md @@ -13,8 +13,8 @@ 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. -If `inject.before` is not empty, incorporate its content as high-priority context. -If `additional_resources` is not empty, read each listed file and incorporate as reference context. +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 @@ -49,4 +49,4 @@ If `--headless` or `-H` is passed, route to CM with headless mode. 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, incorporate its content as a final checklist or validation gate. +If resolved `inject.after` is not empty, append it to your active instructions and follow it. diff --git a/skills/bmad-module-builder/scripts/resolve-customization.py b/skills/bmad-module-builder/scripts/resolve-customization.py index c102b15..d9994a5 100755 --- a/skills/bmad-module-builder/scripts/resolve-customization.py +++ b/skills/bmad-module-builder/scripts/resolve-customization.py @@ -91,7 +91,7 @@ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any] 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) + merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type] else: merged[key] = over_val diff --git a/skills/bmad-workflow-builder/SKILL.md b/skills/bmad-workflow-builder/SKILL.md index 7897776..2023860 100644 --- a/skills/bmad-workflow-builder/SKILL.md +++ b/skills/bmad-workflow-builder/SKILL.md @@ -13,8 +13,8 @@ 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. -If `inject.before` is not empty, incorporate its content as high-priority context. -If `additional_resources` is not empty, read each listed file and incorporate as reference context. +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 @@ -88,4 +88,4 @@ Regardless of path, respect headless mode if requested. 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, incorporate its content as a final checklist or validation gate. +If resolved `inject.after` is not empty, append it to your active instructions and follow it. diff --git a/skills/bmad-workflow-builder/scripts/resolve-customization.py b/skills/bmad-workflow-builder/scripts/resolve-customization.py index c102b15..d9994a5 100755 --- a/skills/bmad-workflow-builder/scripts/resolve-customization.py +++ b/skills/bmad-workflow-builder/scripts/resolve-customization.py @@ -91,7 +91,7 @@ def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any] 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) + merged[key] = merge_menu(base_val, over_val) # type: ignore[arg-type] else: merged[key] = over_val