feat(skills): add customize.toml and inject points to all remaining skills

Add customize.toml with stock fields (inject before/after, additional_resources)
to all 34 remaining workflow and core skills. Copy resolve-customization.py
script into every skill's scripts/ directory. Add customization resolve and
inject points to all workflow SKILL.md files. Strip fallback blocks from all
SKILL.md files since the script ships with every skill.
This commit is contained in:
Brian Madison 2026-04-14 11:03:28 -05:00
parent 5352b33608
commit 64de77298e
109 changed files with 7651 additions and 55 deletions

View File

@ -11,12 +11,6 @@ Resolve `persona`, `inject`, `additional_resources`, and `menu` from customizati
Run: `python ./scripts/resolve-customization.py bmad-agent-analyst --key persona --key inject --key additional_resources --key menu` Run: `python ./scripts/resolve-customization.py bmad-agent-analyst --key persona --key inject --key additional_resources --key menu`
Use the JSON output as resolved values. Use the JSON output as resolved values.
If script unavailable, read these sections from the following files
(first found wins, most specific first):
1. `{project-root}/_bmad/customizations/bmad-agent-analyst.user.toml` (if exists)
2. `{project-root}/_bmad/customizations/bmad-agent-analyst.toml` (if exists)
3. `./customize.toml` (last resort defaults)
### Step 2: Apply Customization ### Step 2: Apply Customization
1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`.

View File

@ -11,12 +11,6 @@ Resolve `persona`, `inject`, `additional_resources`, and `menu` from customizati
Run: `python ./scripts/resolve-customization.py bmad-agent-tech-writer --key persona --key inject --key additional_resources --key menu` Run: `python ./scripts/resolve-customization.py bmad-agent-tech-writer --key persona --key inject --key additional_resources --key menu`
Use the JSON output as resolved values. Use the JSON output as resolved values.
If script unavailable, read these sections from the following files
(first found wins, most specific first):
1. `{project-root}/_bmad/customizations/bmad-agent-tech-writer.user.toml` (if exists)
2. `{project-root}/_bmad/customizations/bmad-agent-tech-writer.toml` (if exists)
3. `./customize.toml` (last resort defaults)
### Step 2: Apply Customization ### Step 2: Apply Customization
1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`.

View File

@ -3,4 +3,20 @@ name: bmad-document-project
description: 'Document brownfield projects for AI context. Use when the user says "document this project" or "generate project docs"' description: 'Document brownfield projects for AI context. Use when the user says "document this project" or "generate project docs"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-document-project --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-document-project --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-document-project
# 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-document-project.toml (team/org, committed to git)
# _bmad/customizations/bmad-document-project.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 = ""

View File

@ -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()

View File

@ -3,6 +3,15 @@ name: bmad-prfaq
description: Working Backwards PRFAQ challenge to forge product concepts. Use when the user requests to 'create a PRFAQ', 'work backwards', or 'run the PRFAQ challenge'. description: Working Backwards PRFAQ challenge to forge product concepts. Use when the user requests to 'create a PRFAQ', 'work backwards', or 'run the PRFAQ challenge'.
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-prfaq --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.
# Working Backwards: The PRFAQ Challenge # Working Backwards: The PRFAQ Challenge
## Overview ## Overview
@ -94,3 +103,10 @@ When the user gets stuck, offer concrete suggestions based on what they've share
| 3 | Customer FAQ | Devil's advocate customer questions | `./references/customer-faq.md` | | 3 | Customer FAQ | Devil's advocate customer questions | `./references/customer-faq.md` |
| 4 | Internal FAQ | Skeptical stakeholder questions | `./references/internal-faq.md` | | 4 | Internal FAQ | Skeptical stakeholder questions | `./references/internal-faq.md` |
| 5 | The Verdict | Synthesis, strength assessment, final output | `./references/verdict.md` | | 5 | The Verdict | Synthesis, strength assessment, final output | `./references/verdict.md` |
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-prfaq --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-prfaq
# 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-prfaq.toml (team/org, committed to git)
# _bmad/customizations/bmad-prfaq.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 = ""

View File

@ -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()

View File

@ -21,12 +21,6 @@ Resolve `inject`, `additional_resources`, and `config.defaultMode` from customiz
Run: `python ./scripts/resolve-customization.py bmad-product-brief --key inject --key additional_resources --key config.defaultMode` Run: `python ./scripts/resolve-customization.py bmad-product-brief --key inject --key additional_resources --key config.defaultMode`
Use the JSON output as resolved values. Use the JSON output as resolved values.
If script unavailable, read these fields from the following files
(first found wins, most specific first):
1. `{project-root}/_bmad/customizations/bmad-product-brief.user.toml` (if exists)
2. `{project-root}/_bmad/customizations/bmad-product-brief.toml` (if exists)
3. `./customize.toml` (last resort defaults)
### Step 2: Apply Activation Customization ### Step 2: Apply Activation Customization
1. **Inject before** -- If `inject.before` is not empty, read and incorporate 1. **Inject before** -- If `inject.before` is not empty, read and incorporate

View File

@ -3,4 +3,20 @@ name: bmad-domain-research
description: 'Conduct domain and industry research. Use when the user says wants to do domain research for a topic or industry' description: 'Conduct domain and industry research. Use when the user says wants to do domain research for a topic or industry'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-domain-research --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-domain-research --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-domain-research
# 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-domain-research.toml (team/org, committed to git)
# _bmad/customizations/bmad-domain-research.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-market-research
description: 'Conduct market research on competition and customers. Use when the user says they need market research' description: 'Conduct market research on competition and customers. Use when the user says they need market research'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-market-research --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-market-research --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-market-research
# 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-market-research.toml (team/org, committed to git)
# _bmad/customizations/bmad-market-research.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-technical-research
description: 'Conduct technical research on technologies and architecture. Use when the user says they would like to do or produce a technical research report' description: 'Conduct technical research on technologies and architecture. Use when the user says they would like to do or produce a technical research report'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-technical-research --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-technical-research --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-technical-research
# 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-technical-research.toml (team/org, committed to git)
# _bmad/customizations/bmad-technical-research.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 = ""

View File

@ -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()

View File

@ -11,12 +11,6 @@ Resolve `persona`, `inject`, `additional_resources`, and `menu` from customizati
Run: `python ./scripts/resolve-customization.py bmad-agent-pm --key persona --key inject --key additional_resources --key menu` Run: `python ./scripts/resolve-customization.py bmad-agent-pm --key persona --key inject --key additional_resources --key menu`
Use the JSON output as resolved values. Use the JSON output as resolved values.
If script unavailable, read these sections from the following files
(first found wins, most specific first):
1. `{project-root}/_bmad/customizations/bmad-agent-pm.user.toml` (if exists)
2. `{project-root}/_bmad/customizations/bmad-agent-pm.toml` (if exists)
3. `./customize.toml` (last resort defaults)
### Step 2: Apply Customization ### Step 2: Apply Customization
1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`.

View File

@ -11,12 +11,6 @@ Resolve `persona`, `inject`, `additional_resources`, and `menu` from customizati
Run: `python ./scripts/resolve-customization.py bmad-agent-ux-designer --key persona --key inject --key additional_resources --key menu` Run: `python ./scripts/resolve-customization.py bmad-agent-ux-designer --key persona --key inject --key additional_resources --key menu`
Use the JSON output as resolved values. Use the JSON output as resolved values.
If script unavailable, read these sections from the following files
(first found wins, most specific first):
1. `{project-root}/_bmad/customizations/bmad-agent-ux-designer.user.toml` (if exists)
2. `{project-root}/_bmad/customizations/bmad-agent-ux-designer.toml` (if exists)
3. `./customize.toml` (last resort defaults)
### Step 2: Apply Customization ### Step 2: Apply Customization
1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`.

View File

@ -3,4 +3,20 @@ name: bmad-create-prd
description: 'Create a PRD from scratch. Use when the user says "lets create a product requirements document" or "I want to create a new PRD"' description: 'Create a PRD from scratch. Use when the user says "lets create a product requirements document" or "I want to create a new PRD"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-prd --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-prd --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-create-prd
# 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-create-prd.toml (team/org, committed to git)
# _bmad/customizations/bmad-create-prd.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-create-ux-design
description: 'Plan UX patterns and design specifications. Use when the user says "lets create UX design" or "create UX specifications" or "help me plan the UX"' description: 'Plan UX patterns and design specifications. Use when the user says "lets create UX design" or "create UX specifications" or "help me plan the UX"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-ux-design --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-ux-design --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-create-ux-design
# 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-create-ux-design.toml (team/org, committed to git)
# _bmad/customizations/bmad-create-ux-design.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-edit-prd
description: 'Edit an existing PRD. Use when the user says "edit this PRD".' description: 'Edit an existing PRD. Use when the user says "edit this PRD".'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-edit-prd --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-edit-prd --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-edit-prd
# 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-edit-prd.toml (team/org, committed to git)
# _bmad/customizations/bmad-edit-prd.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-validate-prd
description: 'Validate a PRD against standards. Use when the user says "validate this PRD" or "run PRD validation"' description: 'Validate a PRD against standards. Use when the user says "validate this PRD" or "run PRD validation"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-validate-prd --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-validate-prd --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-validate-prd
# 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-validate-prd.toml (team/org, committed to git)
# _bmad/customizations/bmad-validate-prd.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 = ""

View File

@ -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()

View File

@ -11,12 +11,6 @@ Resolve `persona`, `inject`, `additional_resources`, and `menu` from customizati
Run: `python ./scripts/resolve-customization.py bmad-agent-architect --key persona --key inject --key additional_resources --key menu` Run: `python ./scripts/resolve-customization.py bmad-agent-architect --key persona --key inject --key additional_resources --key menu`
Use the JSON output as resolved values. Use the JSON output as resolved values.
If script unavailable, read these sections from the following files
(first found wins, most specific first):
1. `{project-root}/_bmad/customizations/bmad-agent-architect.user.toml` (if exists)
2. `{project-root}/_bmad/customizations/bmad-agent-architect.toml` (if exists)
3. `./customize.toml` (last resort defaults)
### Step 2: Apply Customization ### Step 2: Apply Customization
1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`.

View File

@ -3,4 +3,20 @@ name: bmad-check-implementation-readiness
description: 'Validate PRD, UX, Architecture and Epics specs are complete. Use when the user says "check implementation readiness".' description: 'Validate PRD, UX, Architecture and Epics specs are complete. Use when the user says "check implementation readiness".'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-check-implementation-readiness --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-check-implementation-readiness --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-check-implementation-readiness
# 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-check-implementation-readiness.toml (team/org, committed to git)
# _bmad/customizations/bmad-check-implementation-readiness.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-create-architecture
description: 'Create architecture solution design decisions for AI agent consistency. Use when the user says "lets create architecture" or "create technical architecture" or "create a solution design"' description: 'Create architecture solution design decisions for AI agent consistency. Use when the user says "lets create architecture" or "create technical architecture" or "create a solution design"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-architecture --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-architecture --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-create-architecture
# 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-create-architecture.toml (team/org, committed to git)
# _bmad/customizations/bmad-create-architecture.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-create-epics-and-stories
description: 'Break requirements into epics and user stories. Use when the user says "create the epics and stories list"' description: 'Break requirements into epics and user stories. Use when the user says "create the epics and stories list"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-epics-and-stories --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-epics-and-stories --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-create-epics-and-stories
# 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-create-epics-and-stories.toml (team/org, committed to git)
# _bmad/customizations/bmad-create-epics-and-stories.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-generate-project-context
description: 'Create project-context.md with AI rules. Use when the user says "generate project context" or "create project context"' description: 'Create project-context.md with AI rules. Use when the user says "generate project context" or "create project context"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-generate-project-context --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-generate-project-context --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-generate-project-context
# 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-generate-project-context.toml (team/org, committed to git)
# _bmad/customizations/bmad-generate-project-context.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 = ""

View File

@ -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()

View File

@ -11,12 +11,6 @@ Resolve `persona`, `inject`, `additional_resources`, and `menu` from customizati
Run: `python ./scripts/resolve-customization.py bmad-agent-dev --key persona --key inject --key additional_resources --key menu` Run: `python ./scripts/resolve-customization.py bmad-agent-dev --key persona --key inject --key additional_resources --key menu`
Use the JSON output as resolved values. Use the JSON output as resolved values.
If script unavailable, read these sections from the following files
(first found wins, most specific first):
1. `{project-root}/_bmad/customizations/bmad-agent-dev.user.toml` (if exists)
2. `{project-root}/_bmad/customizations/bmad-agent-dev.toml` (if exists)
3. `./customize.toml` (last resort defaults)
### Step 2: Apply Customization ### Step 2: Apply Customization
1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`.

View File

@ -3,6 +3,15 @@ name: bmad-checkpoint-preview
description: 'LLM-assisted human-in-the-loop review. Make sense of a change, focus attention where it matters, test. Use when the user says "checkpoint", "human review", or "walk me through this change".' description: 'LLM-assisted human-in-the-loop review. Make sense of a change, focus attention where it matters, test. Use when the user says "checkpoint", "human review", or "walk me through this change".'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-checkpoint-preview --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.
# Checkpoint Review Workflow # Checkpoint Review Workflow
**Goal:** Guide a human through reviewing a change — from purpose and context into details. **Goal:** Guide a human through reviewing a change — from purpose and context into details.
@ -27,3 +36,10 @@ Load and read full config from `{project-root}/_bmad/bmm/config.yaml` and resolv
## FIRST STEP ## FIRST STEP
Read fully and follow `./step-01-orientation.md` to begin. Read fully and follow `./step-01-orientation.md` to begin.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-checkpoint-preview --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-checkpoint-preview
# 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-checkpoint-preview.toml (team/org, committed to git)
# _bmad/customizations/bmad-checkpoint-preview.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-code-review
description: 'Review code changes adversarially using parallel review layers (Blind Hunter, Edge Case Hunter, Acceptance Auditor) with structured triage into actionable categories. Use when the user says "run code review" or "review this code"' description: 'Review code changes adversarially using parallel review layers (Blind Hunter, Edge Case Hunter, Acceptance Auditor) with structured triage into actionable categories. Use when the user says "run code review" or "review this code"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-code-review --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-code-review --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-code-review
# 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-code-review.toml (team/org, committed to git)
# _bmad/customizations/bmad-code-review.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-correct-course
description: 'Manage significant changes during sprint execution. Use when the user says "correct course" or "propose sprint change"' description: 'Manage significant changes during sprint execution. Use when the user says "correct course" or "propose sprint change"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-correct-course --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-correct-course --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-correct-course
# 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-correct-course.toml (team/org, committed to git)
# _bmad/customizations/bmad-correct-course.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-create-story
description: 'Creates a dedicated story file with all the context the agent will need to implement it later. Use when the user says "create the next story" or "create story [story identifier]"' description: 'Creates a dedicated story file with all the context the agent will need to implement it later. Use when the user says "create the next story" or "create story [story identifier]"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-story --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-create-story --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-create-story
# 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-create-story.toml (team/org, committed to git)
# _bmad/customizations/bmad-create-story.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-dev-story
description: 'Execute story implementation following a context filled story spec file. Use when the user says "dev this story [story file]" or "implement the next story in the sprint plan"' description: 'Execute story implementation following a context filled story spec file. Use when the user says "dev this story [story file]" or "implement the next story in the sprint plan"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-dev-story --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-dev-story --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-dev-story
# 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-dev-story.toml (team/org, committed to git)
# _bmad/customizations/bmad-dev-story.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-qa-generate-e2e-tests
description: 'Generate end to end automated tests for existing features. Use when the user says "create qa automated tests for [feature]"' description: 'Generate end to end automated tests for existing features. Use when the user says "create qa automated tests for [feature]"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-qa-generate-e2e-tests --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-qa-generate-e2e-tests --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-qa-generate-e2e-tests
# 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-qa-generate-e2e-tests.toml (team/org, committed to git)
# _bmad/customizations/bmad-qa-generate-e2e-tests.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-quick-dev
description: 'Implements any user intent, requirement, story, bug fix or change request by producing clean working code artifacts that follow the project''s existing architecture, patterns and conventions. Use when the user wants to build, fix, tweak, refactor, add or modify any code, component or feature.' description: 'Implements any user intent, requirement, story, bug fix or change request by producing clean working code artifacts that follow the project''s existing architecture, patterns and conventions. Use when the user wants to build, fix, tweak, refactor, add or modify any code, component or feature.'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-quick-dev --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-quick-dev --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-quick-dev
# 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-quick-dev.toml (team/org, committed to git)
# _bmad/customizations/bmad-quick-dev.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-retrospective
description: 'Post-epic review to extract lessons and assess success. Use when the user says "run a retrospective" or "lets retro the epic [epic]"' description: 'Post-epic review to extract lessons and assess success. Use when the user says "run a retrospective" or "lets retro the epic [epic]"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-retrospective --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-retrospective --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-retrospective
# 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-retrospective.toml (team/org, committed to git)
# _bmad/customizations/bmad-retrospective.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-sprint-planning
description: 'Generate sprint status tracking from epics. Use when the user says "run sprint planning" or "generate sprint plan"' description: 'Generate sprint status tracking from epics. Use when the user says "run sprint planning" or "generate sprint plan"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-sprint-planning --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-sprint-planning --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-sprint-planning
# 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-sprint-planning.toml (team/org, committed to git)
# _bmad/customizations/bmad-sprint-planning.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-sprint-status
description: 'Summarize sprint status and surface risks. Use when the user says "check sprint status" or "show sprint status"' description: 'Summarize sprint status and surface risks. Use when the user says "check sprint status" or "show sprint status"'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-sprint-status --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-sprint-status --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-sprint-status
# 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-sprint-status.toml (team/org, committed to git)
# _bmad/customizations/bmad-sprint-status.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 = ""

View File

@ -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()

View File

@ -3,6 +3,15 @@ name: bmad-advanced-elicitation
description: 'Push the LLM to reconsider, refine, and improve its recent output. Use when user asks for deeper critique or mentions a known deeper critique method, e.g. socratic, first principles, pre-mortem, red team.' description: 'Push the LLM to reconsider, refine, and improve its recent output. Use when user asks for deeper critique or mentions a known deeper critique method, e.g. socratic, first principles, pre-mortem, red team.'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-advanced-elicitation --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.
# Advanced Elicitation # Advanced Elicitation
**Goal:** Push the LLM to reconsider, refine, and improve its recent output. **Goal:** Push the LLM to reconsider, refine, and improve its recent output.
@ -134,3 +143,10 @@ x. Proceed / No Further Actions
1. Apply to the current enhanced version of the content 1. Apply to the current enhanced version of the content
2. Show the improvements made 2. Show the improvements made
3. Return to the prompt for additional elicitations or completion 3. Return to the prompt for additional elicitations or completion
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-advanced-elicitation --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-advanced-elicitation
# 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-advanced-elicitation.toml (team/org, committed to git)
# _bmad/customizations/bmad-advanced-elicitation.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 = ""

View File

@ -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()

View File

@ -3,4 +3,20 @@ name: bmad-brainstorming
description: 'Facilitate interactive brainstorming sessions using diverse creative techniques and ideation methods. Use when the user says help me brainstorm or help me ideate.' description: 'Facilitate interactive brainstorming sessions using diverse creative techniques and ideation methods. Use when the user says help me brainstorm or help me ideate.'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-brainstorming --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.
Follow the instructions in ./workflow.md. Follow the instructions in ./workflow.md.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-brainstorming --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-brainstorming
# 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-brainstorming.toml (team/org, committed to git)
# _bmad/customizations/bmad-brainstorming.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 = ""

View File

@ -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()

View File

@ -3,6 +3,15 @@ name: bmad-distillator
description: Lossless LLM-optimized compression of source documents. Use when the user requests to 'distill documents' or 'create a distillate'. description: Lossless LLM-optimized compression of source documents. Use when the user requests to 'distill documents' or 'create a distillate'.
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-distillator --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.
# Distillator: A Document Distillation Engine # Distillator: A Document Distillation Engine
## Overview ## Overview
@ -175,3 +184,10 @@ This stage proves the distillate is lossless by reconstructing source documents
5. **If gaps are found**, offer to run a targeted fix pass on the distillate — adding the missing information without full recompression. Limit to 2 fix passes maximum. 5. **If gaps are found**, offer to run a targeted fix pass on the distillate — adding the missing information without full recompression. Limit to 2 fix passes maximum.
6. **Clean up** — delete the temporary reconstruction files after the report is generated. 6. **Clean up** — delete the temporary reconstruction files after the report is generated.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-distillator --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-distillator
# 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-distillator.toml (team/org, committed to git)
# _bmad/customizations/bmad-distillator.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 = ""

View File

@ -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()

View File

@ -3,6 +3,15 @@ name: bmad-editorial-review-prose
description: 'Clinical copy-editor that reviews text for communication issues. Use when user says review for prose or improve the prose' description: 'Clinical copy-editor that reviews text for communication issues. Use when user says review for prose or improve the prose'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-editorial-review-prose --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.
# Editorial Review - Prose # Editorial Review - Prose
**Goal:** Review text for communication issues that impede comprehension and output suggested fixes in a three-column table. **Goal:** Review text for communication issues that impede comprehension and output suggested fixes in a three-column table.
@ -16,7 +25,6 @@ description: 'Clinical copy-editor that reviews text for communication issues. U
- **style_guide** (optional) — Project-specific style guide. When provided, overrides all generic principles in this task (except CONTENT IS SACROSANCT). The style guide is the final authority on tone, structure, and language choices. - **style_guide** (optional) — Project-specific style guide. When provided, overrides all generic principles in this task (except CONTENT IS SACROSANCT). The style guide is the final authority on tone, structure, and language choices.
- **reader_type** (optional, default: `humans`) — `humans` for standard editorial, `llm` for precision focus - **reader_type** (optional, default: `humans`) — `humans` for standard editorial, `llm` for precision focus
## PRINCIPLES ## PRINCIPLES
1. **Minimal intervention:** Apply the smallest fix that achieves clarity 1. **Minimal intervention:** Apply the smallest fix that achieves clarity
@ -29,7 +37,6 @@ description: 'Clinical copy-editor that reviews text for communication issues. U
> **STYLE GUIDE OVERRIDE:** If a style_guide input is provided, it overrides ALL generic principles in this task (including the Microsoft Writing Style Guide baseline and reader_type-specific priorities). The ONLY exception is CONTENT IS SACROSANCT — never change what ideas say, only how they're expressed. When style guide conflicts with this task, style guide wins. > **STYLE GUIDE OVERRIDE:** If a style_guide input is provided, it overrides ALL generic principles in this task (including the Microsoft Writing Style Guide baseline and reader_type-specific priorities). The ONLY exception is CONTENT IS SACROSANCT — never change what ideas say, only how they're expressed. When style guide conflicts with this task, style guide wins.
## STEPS ## STEPS
### Step 1: Validate Input ### Step 1: Validate Input
@ -78,9 +85,15 @@ description: 'Clinical copy-editor that reviews text for communication issues. U
| The system will processes data and it handles errors. | The system processes data and handles errors. | Fixed subject-verb agreement ("will processes" to "processes"); removed redundant "it" | | The system will processes data and it handles errors. | The system processes data and handles errors. | Fixed subject-verb agreement ("will processes" to "processes"); removed redundant "it" |
| Users can chose from options (lines 12, 45, 78) | Users can choose from options | Fixed spelling: "chose" to "choose" (appears in 3 locations) | | Users can chose from options (lines 12, 45, 78) | Users can choose from options | Fixed spelling: "chose" to "choose" (appears in 3 locations) |
## HALT CONDITIONS ## HALT CONDITIONS
- HALT with error if content is empty or fewer than 3 words - HALT with error if content is empty or fewer than 3 words
- HALT with error if reader_type is not `humans` or `llm` - HALT with error if reader_type is not `humans` or `llm`
- If no issues found after thorough review, output "No editorial issues identified" (this is valid completion, not an error) - If no issues found after thorough review, output "No editorial issues identified" (this is valid completion, not an error)
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-editorial-review-prose --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-editorial-review-prose
# 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-editorial-review-prose.toml (team/org, committed to git)
# _bmad/customizations/bmad-editorial-review-prose.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 = ""

View File

@ -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()

View File

@ -3,6 +3,15 @@ name: bmad-editorial-review-structure
description: 'Structural editor that proposes cuts, reorganization, and simplification while preserving comprehension. Use when user requests structural review or editorial review of structure' description: 'Structural editor that proposes cuts, reorganization, and simplification while preserving comprehension. Use when user requests structural review or editorial review of structure'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-editorial-review-structure --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.
# Editorial Review - Structure # Editorial Review - Structure
**Goal:** Review document structure and propose substantive changes to improve clarity and flow -- run this BEFORE copy editing. **Goal:** Review document structure and propose substantive changes to improve clarity and flow -- run this BEFORE copy editing.
@ -177,3 +186,10 @@ Use the following output format:
- HALT with error if content is empty or fewer than 3 words - HALT with error if content is empty or fewer than 3 words
- HALT with error if reader_type is not "humans" or "llm" - HALT with error if reader_type is not "humans" or "llm"
- If no structural issues found, output "No substantive changes recommended" (this is valid completion, not an error) - If no structural issues found, output "No substantive changes recommended" (this is valid completion, not an error)
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-editorial-review-structure --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-editorial-review-structure
# 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-editorial-review-structure.toml (team/org, committed to git)
# _bmad/customizations/bmad-editorial-review-structure.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 = ""

View File

@ -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()

View File

@ -3,6 +3,15 @@ name: bmad-help
description: 'Analyzes current state and user query to answer BMad questions or recommend the next skill(s) to use. Use when user asks for help, bmad help, what to do next, or what to start with in BMad.' description: 'Analyzes current state and user query to answer BMad questions or recommend the next skill(s) to use. Use when user asks for help, bmad help, what to do next, or what to start with in BMad.'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-help --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 Help # BMad Help
## Purpose ## Purpose
@ -73,3 +82,10 @@ For each recommended item, present:
- Recommend running each skill in a **fresh context window** - Recommend running each skill in a **fresh context window**
- Match the user's tone — conversational when they're casual, structured when they want specifics - Match the user's tone — conversational when they're casual, structured when they want specifics
- If the active module is ambiguous, retrieve all meta rows remote sources to find relevant info also to help answer their question - If the active module is ambiguous, retrieve all meta rows remote sources to find relevant info also to help answer their question
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-help --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-help
# 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-help.toml (team/org, committed to git)
# _bmad/customizations/bmad-help.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 = ""

View File

@ -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()

View File

@ -3,11 +3,19 @@ name: bmad-index-docs
description: 'Generates or updates an index.md to reference all docs in the folder. Use if user requests to create or update an index of all files in a specific folder' description: 'Generates or updates an index.md to reference all docs in the folder. Use if user requests to create or update an index of all files in a specific folder'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-index-docs --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.
# Index Docs # Index Docs
**Goal:** Generate or update an index.md to reference all docs in a target folder. **Goal:** Generate or update an index.md to reference all docs in a target folder.
## EXECUTION ## EXECUTION
### Step 1: Scan Directory ### Step 1: Scan Directory
@ -26,7 +34,6 @@ description: 'Generates or updates an index.md to reference all docs in the fold
- Write or update index.md with organized file listings - Write or update index.md with organized file listings
## OUTPUT FORMAT ## OUTPUT FORMAT
```markdown ```markdown
@ -49,13 +56,11 @@ description: 'Generates or updates an index.md to reference all docs in the fold
- **[file3.ext](./another-folder/file3.ext)** - Brief description - **[file3.ext](./another-folder/file3.ext)** - Brief description
``` ```
## HALT CONDITIONS ## HALT CONDITIONS
- HALT if target directory does not exist or is inaccessible - HALT if target directory does not exist or is inaccessible
- HALT if user does not have write permissions to create index.md - HALT if user does not have write permissions to create index.md
## VALIDATION ## VALIDATION
- Use relative paths starting with ./ - Use relative paths starting with ./
@ -64,3 +69,10 @@ description: 'Generates or updates an index.md to reference all docs in the fold
- Keep descriptions concise but informative (3-10 words) - Keep descriptions concise but informative (3-10 words)
- Sort alphabetically within groups - Sort alphabetically within groups
- Skip hidden files (starting with .) unless specified - Skip hidden files (starting with .) unless specified
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-index-docs --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-index-docs
# 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-index-docs.toml (team/org, committed to git)
# _bmad/customizations/bmad-index-docs.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 = ""

View File

@ -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()

View File

@ -3,6 +3,15 @@ name: bmad-party-mode
description: 'Orchestrates group discussions between installed BMAD agents, enabling natural multi-agent conversations where each agent is a real subagent with independent thinking. Use when user requests party mode, wants multiple agent perspectives, group discussion, roundtable, or multi-agent conversation about their project.' description: 'Orchestrates group discussions between installed BMAD agents, enabling natural multi-agent conversations where each agent is a real subagent with independent thinking. Use when user requests party mode, wants multiple agent perspectives, group discussion, roundtable, or multi-agent conversation about their project.'
--- ---
## Resolve Customization
Resolve `inject` and `additional_resources` from customization:
Run: `python ./scripts/resolve-customization.py bmad-party-mode --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.
# Party Mode # Party Mode
Facilitate roundtable discussions where BMAD agents participate as **real subagents** — each spawned independently via the Agent tool so they think for themselves. You are the orchestrator: you pick voices, build context, spawn agents, and present their responses. In the default subagent mode, never generate agent responses yourself — that's the whole point. In `--solo` mode, you roleplay all agents directly. Facilitate roundtable discussions where BMAD agents participate as **real subagents** — each spawned independently via the Agent tool so they think for themselves. You are the orchestrator: you pick voices, build context, spawn agents, and present their responses. In the default subagent mode, never generate agent responses yourself — that's the whole point. In `--solo` mode, you roleplay all agents directly.
@ -123,3 +132,10 @@ As the conversation grows, you'll need to summarize prior rounds rather than pas
## Exit ## Exit
When the user says they're done (any natural phrasing — "thanks", "that's all", "end party mode", etc.), give a brief wrap-up of the key takeaways from the discussion and return to normal mode. Don't force exit triggers — just read the room. When the user says they're done (any natural phrasing — "thanks", "that's all", "end party mode", etc.), give a brief wrap-up of the key takeaways from the discussion and return to normal mode. Don't force exit triggers — just read the room.
## Post-Workflow Customization
After the workflow completes, resolve `inject.after` from customization:
Run: `python ./scripts/resolve-customization.py bmad-party-mode --key inject.after`
If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate.

View File

@ -0,0 +1,27 @@
# ──────────────────────────────────────────────────────────────────
# Customization Defaults: bmad-party-mode
# 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-party-mode.toml (team/org, committed to git)
# _bmad/customizations/bmad-party-mode.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 = ""

View File

@ -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()

Some files were not shown because too many files have changed in this diff Show More