From 64de77298e197e1cba69beaeb26e9d547c8df074 Mon Sep 17 00:00:00 2001 From: Brian Madison Date: Tue, 14 Apr 2026 11:03:28 -0500 Subject: [PATCH] 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. --- .../1-analysis/bmad-agent-analyst/SKILL.md | 6 - .../bmad-agent-tech-writer/SKILL.md | 6 - .../1-analysis/bmad-document-project/SKILL.md | 16 ++ .../bmad-document-project/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md | 16 ++ .../1-analysis/bmad-prfaq/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../1-analysis/bmad-product-brief/SKILL.md | 6 - .../research/bmad-domain-research/SKILL.md | 16 ++ .../bmad-domain-research/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../research/bmad-market-research/SKILL.md | 16 ++ .../bmad-market-research/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../research/bmad-technical-research/SKILL.md | 16 ++ .../bmad-technical-research/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../2-plan-workflows/bmad-agent-pm/SKILL.md | 6 - .../bmad-agent-ux-designer/SKILL.md | 6 - .../2-plan-workflows/bmad-create-prd/SKILL.md | 16 ++ .../bmad-create-prd/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-create-ux-design/SKILL.md | 16 ++ .../bmad-create-ux-design/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../2-plan-workflows/bmad-edit-prd/SKILL.md | 16 ++ .../bmad-edit-prd/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-validate-prd/SKILL.md | 16 ++ .../bmad-validate-prd/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-agent-architect/SKILL.md | 6 - .../SKILL.md | 16 ++ .../customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-create-architecture/SKILL.md | 16 ++ .../bmad-create-architecture/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-create-epics-and-stories/SKILL.md | 16 ++ .../customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-generate-project-context/SKILL.md | 16 ++ .../customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../4-implementation/bmad-agent-dev/SKILL.md | 6 - .../bmad-checkpoint-preview/SKILL.md | 16 ++ .../bmad-checkpoint-preview/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-code-review/SKILL.md | 16 ++ .../bmad-code-review/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-correct-course/SKILL.md | 16 ++ .../bmad-correct-course/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-create-story/SKILL.md | 16 ++ .../bmad-create-story/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../4-implementation/bmad-dev-story/SKILL.md | 16 ++ .../bmad-dev-story/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-qa-generate-e2e-tests/SKILL.md | 16 ++ .../bmad-qa-generate-e2e-tests/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../4-implementation/bmad-quick-dev/SKILL.md | 16 ++ .../bmad-quick-dev/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-retrospective/SKILL.md | 16 ++ .../bmad-retrospective/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-sprint-planning/SKILL.md | 16 ++ .../bmad-sprint-planning/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-sprint-status/SKILL.md | 16 ++ .../bmad-sprint-status/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-advanced-elicitation/SKILL.md | 16 ++ .../bmad-advanced-elicitation/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ src/core-skills/bmad-brainstorming/SKILL.md | 16 ++ .../bmad-brainstorming/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ src/core-skills/bmad-distillator/SKILL.md | 18 +- .../bmad-distillator/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-editorial-review-prose/SKILL.md | 19 +- .../customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-editorial-review-structure/SKILL.md | 16 ++ .../customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ src/core-skills/bmad-help/SKILL.md | 16 ++ src/core-skills/bmad-help/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ src/core-skills/bmad-index-docs/SKILL.md | 20 +- .../bmad-index-docs/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ src/core-skills/bmad-party-mode/SKILL.md | 16 ++ .../bmad-party-mode/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-review-adversarial-general/SKILL.md | 18 +- .../customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ .../bmad-review-edge-case-hunter/SKILL.md | 19 +- .../customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ src/core-skills/bmad-shard-doc/SKILL.md | 16 ++ src/core-skills/bmad-shard-doc/customize.toml | 27 +++ .../scripts/resolve-customization.py | 182 ++++++++++++++++++ 109 files changed, 7651 insertions(+), 55 deletions(-) create mode 100644 src/bmm-skills/1-analysis/bmad-document-project/customize.toml create mode 100755 src/bmm-skills/1-analysis/bmad-document-project/scripts/resolve-customization.py create mode 100644 src/bmm-skills/1-analysis/bmad-prfaq/customize.toml create mode 100755 src/bmm-skills/1-analysis/bmad-prfaq/scripts/resolve-customization.py create mode 100644 src/bmm-skills/1-analysis/research/bmad-domain-research/customize.toml create mode 100755 src/bmm-skills/1-analysis/research/bmad-domain-research/scripts/resolve-customization.py create mode 100644 src/bmm-skills/1-analysis/research/bmad-market-research/customize.toml create mode 100755 src/bmm-skills/1-analysis/research/bmad-market-research/scripts/resolve-customization.py create mode 100644 src/bmm-skills/1-analysis/research/bmad-technical-research/customize.toml create mode 100755 src/bmm-skills/1-analysis/research/bmad-technical-research/scripts/resolve-customization.py create mode 100644 src/bmm-skills/2-plan-workflows/bmad-create-prd/customize.toml create mode 100755 src/bmm-skills/2-plan-workflows/bmad-create-prd/scripts/resolve-customization.py create mode 100644 src/bmm-skills/2-plan-workflows/bmad-create-ux-design/customize.toml create mode 100755 src/bmm-skills/2-plan-workflows/bmad-create-ux-design/scripts/resolve-customization.py create mode 100644 src/bmm-skills/2-plan-workflows/bmad-edit-prd/customize.toml create mode 100755 src/bmm-skills/2-plan-workflows/bmad-edit-prd/scripts/resolve-customization.py create mode 100644 src/bmm-skills/2-plan-workflows/bmad-validate-prd/customize.toml create mode 100755 src/bmm-skills/2-plan-workflows/bmad-validate-prd/scripts/resolve-customization.py create mode 100644 src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/customize.toml create mode 100755 src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/scripts/resolve-customization.py create mode 100644 src/bmm-skills/3-solutioning/bmad-create-architecture/customize.toml create mode 100755 src/bmm-skills/3-solutioning/bmad-create-architecture/scripts/resolve-customization.py create mode 100644 src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml create mode 100755 src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/resolve-customization.py create mode 100644 src/bmm-skills/3-solutioning/bmad-generate-project-context/customize.toml create mode 100755 src/bmm-skills/3-solutioning/bmad-generate-project-context/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-checkpoint-preview/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-checkpoint-preview/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-code-review/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-code-review/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-correct-course/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-correct-course/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-create-story/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-create-story/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-dev-story/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-dev-story/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-quick-dev/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-quick-dev/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-retrospective/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-retrospective/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-sprint-planning/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-sprint-planning/scripts/resolve-customization.py create mode 100644 src/bmm-skills/4-implementation/bmad-sprint-status/customize.toml create mode 100755 src/bmm-skills/4-implementation/bmad-sprint-status/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-advanced-elicitation/customize.toml create mode 100755 src/core-skills/bmad-advanced-elicitation/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-brainstorming/customize.toml create mode 100755 src/core-skills/bmad-brainstorming/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-distillator/customize.toml create mode 100755 src/core-skills/bmad-distillator/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-editorial-review-prose/customize.toml create mode 100755 src/core-skills/bmad-editorial-review-prose/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-editorial-review-structure/customize.toml create mode 100755 src/core-skills/bmad-editorial-review-structure/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-help/customize.toml create mode 100755 src/core-skills/bmad-help/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-index-docs/customize.toml create mode 100755 src/core-skills/bmad-index-docs/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-party-mode/customize.toml create mode 100755 src/core-skills/bmad-party-mode/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-review-adversarial-general/customize.toml create mode 100755 src/core-skills/bmad-review-adversarial-general/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-review-edge-case-hunter/customize.toml create mode 100755 src/core-skills/bmad-review-edge-case-hunter/scripts/resolve-customization.py create mode 100644 src/core-skills/bmad-shard-doc/customize.toml create mode 100755 src/core-skills/bmad-shard-doc/scripts/resolve-customization.py diff --git a/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md b/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md index 67f9e458e..3fb2950ae 100644 --- a/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md +++ b/src/bmm-skills/1-analysis/bmad-agent-analyst/SKILL.md @@ -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` 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 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. diff --git a/src/bmm-skills/1-analysis/bmad-agent-tech-writer/SKILL.md b/src/bmm-skills/1-analysis/bmad-agent-tech-writer/SKILL.md index e59fb2ae3..21b3ff341 100644 --- a/src/bmm-skills/1-analysis/bmad-agent-tech-writer/SKILL.md +++ b/src/bmm-skills/1-analysis/bmad-agent-tech-writer/SKILL.md @@ -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` 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 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. diff --git a/src/bmm-skills/1-analysis/bmad-document-project/SKILL.md b/src/bmm-skills/1-analysis/bmad-document-project/SKILL.md index 09422e159..47f0c26be 100644 --- a/src/bmm-skills/1-analysis/bmad-document-project/SKILL.md +++ b/src/bmm-skills/1-analysis/bmad-document-project/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/1-analysis/bmad-document-project/customize.toml b/src/bmm-skills/1-analysis/bmad-document-project/customize.toml new file mode 100644 index 000000000..779e2f06b --- /dev/null +++ b/src/bmm-skills/1-analysis/bmad-document-project/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/1-analysis/bmad-document-project/scripts/resolve-customization.py b/src/bmm-skills/1-analysis/bmad-document-project/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/1-analysis/bmad-document-project/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md b/src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md index 36e9b3ba4..4aea408a4 100644 --- a/src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md +++ b/src/bmm-skills/1-analysis/bmad-prfaq/SKILL.md @@ -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'. --- +## 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 ## 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` | | 4 | Internal FAQ | Skeptical stakeholder questions | `./references/internal-faq.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. diff --git a/src/bmm-skills/1-analysis/bmad-prfaq/customize.toml b/src/bmm-skills/1-analysis/bmad-prfaq/customize.toml new file mode 100644 index 000000000..598d74128 --- /dev/null +++ b/src/bmm-skills/1-analysis/bmad-prfaq/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/1-analysis/bmad-prfaq/scripts/resolve-customization.py b/src/bmm-skills/1-analysis/bmad-prfaq/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/1-analysis/bmad-prfaq/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md b/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md index b1f8654eb..7e3f7feab 100644 --- a/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md +++ b/src/bmm-skills/1-analysis/bmad-product-brief/SKILL.md @@ -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` 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 1. **Inject before** -- If `inject.before` is not empty, read and incorporate diff --git a/src/bmm-skills/1-analysis/research/bmad-domain-research/SKILL.md b/src/bmm-skills/1-analysis/research/bmad-domain-research/SKILL.md index b3dbc128f..0e18abaea 100644 --- a/src/bmm-skills/1-analysis/research/bmad-domain-research/SKILL.md +++ b/src/bmm-skills/1-analysis/research/bmad-domain-research/SKILL.md @@ -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' --- +## 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. + +## 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. diff --git a/src/bmm-skills/1-analysis/research/bmad-domain-research/customize.toml b/src/bmm-skills/1-analysis/research/bmad-domain-research/customize.toml new file mode 100644 index 000000000..0a63daae2 --- /dev/null +++ b/src/bmm-skills/1-analysis/research/bmad-domain-research/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/1-analysis/research/bmad-domain-research/scripts/resolve-customization.py b/src/bmm-skills/1-analysis/research/bmad-domain-research/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/1-analysis/research/bmad-domain-research/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/1-analysis/research/bmad-market-research/SKILL.md b/src/bmm-skills/1-analysis/research/bmad-market-research/SKILL.md index bf509851d..c5288cce4 100644 --- a/src/bmm-skills/1-analysis/research/bmad-market-research/SKILL.md +++ b/src/bmm-skills/1-analysis/research/bmad-market-research/SKILL.md @@ -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' --- +## 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. + +## 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. diff --git a/src/bmm-skills/1-analysis/research/bmad-market-research/customize.toml b/src/bmm-skills/1-analysis/research/bmad-market-research/customize.toml new file mode 100644 index 000000000..480c05a19 --- /dev/null +++ b/src/bmm-skills/1-analysis/research/bmad-market-research/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/1-analysis/research/bmad-market-research/scripts/resolve-customization.py b/src/bmm-skills/1-analysis/research/bmad-market-research/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/1-analysis/research/bmad-market-research/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/1-analysis/research/bmad-technical-research/SKILL.md b/src/bmm-skills/1-analysis/research/bmad-technical-research/SKILL.md index 8524fd647..47312121a 100644 --- a/src/bmm-skills/1-analysis/research/bmad-technical-research/SKILL.md +++ b/src/bmm-skills/1-analysis/research/bmad-technical-research/SKILL.md @@ -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' --- +## 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. + +## 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. diff --git a/src/bmm-skills/1-analysis/research/bmad-technical-research/customize.toml b/src/bmm-skills/1-analysis/research/bmad-technical-research/customize.toml new file mode 100644 index 000000000..9d5a82242 --- /dev/null +++ b/src/bmm-skills/1-analysis/research/bmad-technical-research/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/1-analysis/research/bmad-technical-research/scripts/resolve-customization.py b/src/bmm-skills/1-analysis/research/bmad-technical-research/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/1-analysis/research/bmad-technical-research/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/2-plan-workflows/bmad-agent-pm/SKILL.md b/src/bmm-skills/2-plan-workflows/bmad-agent-pm/SKILL.md index f97e1d25d..8f195f242 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-agent-pm/SKILL.md +++ b/src/bmm-skills/2-plan-workflows/bmad-agent-pm/SKILL.md @@ -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` 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 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. diff --git a/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/SKILL.md b/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/SKILL.md index a6e46c059..b7dc749df 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/SKILL.md +++ b/src/bmm-skills/2-plan-workflows/bmad-agent-ux-designer/SKILL.md @@ -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` 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 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. diff --git a/src/bmm-skills/2-plan-workflows/bmad-create-prd/SKILL.md b/src/bmm-skills/2-plan-workflows/bmad-create-prd/SKILL.md index 54f764032..b18cce4bc 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-create-prd/SKILL.md +++ b/src/bmm-skills/2-plan-workflows/bmad-create-prd/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/2-plan-workflows/bmad-create-prd/customize.toml b/src/bmm-skills/2-plan-workflows/bmad-create-prd/customize.toml new file mode 100644 index 000000000..6c2a70aae --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-create-prd/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/2-plan-workflows/bmad-create-prd/scripts/resolve-customization.py b/src/bmm-skills/2-plan-workflows/bmad-create-prd/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-create-prd/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/SKILL.md b/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/SKILL.md index 96079575b..24b8b68e0 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/SKILL.md +++ b/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/customize.toml b/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/customize.toml new file mode 100644 index 000000000..0dab39809 --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/scripts/resolve-customization.py b/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-create-ux-design/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/SKILL.md b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/SKILL.md index b16498d39..6e7c9ba8f 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/SKILL.md +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/SKILL.md @@ -3,4 +3,20 @@ name: bmad-edit-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. + +## 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. diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/customize.toml b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/customize.toml new file mode 100644 index 000000000..79ea7058b --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/2-plan-workflows/bmad-edit-prd/scripts/resolve-customization.py b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-edit-prd/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/2-plan-workflows/bmad-validate-prd/SKILL.md b/src/bmm-skills/2-plan-workflows/bmad-validate-prd/SKILL.md index 77b523b81..97aaa9109 100644 --- a/src/bmm-skills/2-plan-workflows/bmad-validate-prd/SKILL.md +++ b/src/bmm-skills/2-plan-workflows/bmad-validate-prd/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/2-plan-workflows/bmad-validate-prd/customize.toml b/src/bmm-skills/2-plan-workflows/bmad-validate-prd/customize.toml new file mode 100644 index 000000000..8d39cb4bc --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-validate-prd/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/2-plan-workflows/bmad-validate-prd/scripts/resolve-customization.py b/src/bmm-skills/2-plan-workflows/bmad-validate-prd/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/2-plan-workflows/bmad-validate-prd/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md b/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md index d8129cef7..87d5ae6b7 100644 --- a/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md +++ b/src/bmm-skills/3-solutioning/bmad-agent-architect/SKILL.md @@ -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` 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 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. diff --git a/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/SKILL.md b/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/SKILL.md index d5ba0903f..3cafe1033 100644 --- a/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/SKILL.md +++ b/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/SKILL.md @@ -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".' --- +## 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. + +## 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. diff --git a/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/customize.toml b/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/customize.toml new file mode 100644 index 000000000..2b7eb474b --- /dev/null +++ b/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/scripts/resolve-customization.py b/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/3-solutioning/bmad-check-implementation-readiness/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/3-solutioning/bmad-create-architecture/SKILL.md b/src/bmm-skills/3-solutioning/bmad-create-architecture/SKILL.md index 27d4c7e66..c74e88fdb 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-architecture/SKILL.md +++ b/src/bmm-skills/3-solutioning/bmad-create-architecture/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/3-solutioning/bmad-create-architecture/customize.toml b/src/bmm-skills/3-solutioning/bmad-create-architecture/customize.toml new file mode 100644 index 000000000..fb3b2ab39 --- /dev/null +++ b/src/bmm-skills/3-solutioning/bmad-create-architecture/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/3-solutioning/bmad-create-architecture/scripts/resolve-customization.py b/src/bmm-skills/3-solutioning/bmad-create-architecture/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/3-solutioning/bmad-create-architecture/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md index d092487dc..fe7f5c0dd 100644 --- a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml new file mode 100644 index 000000000..13ff595f3 --- /dev/null +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/resolve-customization.py b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/3-solutioning/bmad-create-epics-and-stories/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/3-solutioning/bmad-generate-project-context/SKILL.md b/src/bmm-skills/3-solutioning/bmad-generate-project-context/SKILL.md index e54067b14..783d0bb57 100644 --- a/src/bmm-skills/3-solutioning/bmad-generate-project-context/SKILL.md +++ b/src/bmm-skills/3-solutioning/bmad-generate-project-context/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/3-solutioning/bmad-generate-project-context/customize.toml b/src/bmm-skills/3-solutioning/bmad-generate-project-context/customize.toml new file mode 100644 index 000000000..25861f2bc --- /dev/null +++ b/src/bmm-skills/3-solutioning/bmad-generate-project-context/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/3-solutioning/bmad-generate-project-context/scripts/resolve-customization.py b/src/bmm-skills/3-solutioning/bmad-generate-project-context/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/3-solutioning/bmad-generate-project-context/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md b/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md index ddf9d046f..e13caf6a4 100644 --- a/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-agent-dev/SKILL.md @@ -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` 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 1. **Adopt persona** -- You are `{persona.displayName}`, `{persona.title}`. diff --git a/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md b/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md index 2cfd04420..2891d6b1f 100644 --- a/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-checkpoint-preview/SKILL.md @@ -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".' --- +## 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 **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 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. diff --git a/src/bmm-skills/4-implementation/bmad-checkpoint-preview/customize.toml b/src/bmm-skills/4-implementation/bmad-checkpoint-preview/customize.toml new file mode 100644 index 000000000..40d79aa7d --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-checkpoint-preview/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-checkpoint-preview/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-checkpoint-preview/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-checkpoint-preview/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-code-review/SKILL.md b/src/bmm-skills/4-implementation/bmad-code-review/SKILL.md index 32f020af7..1bac910c3 100644 --- a/src/bmm-skills/4-implementation/bmad-code-review/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-code-review/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-code-review/customize.toml b/src/bmm-skills/4-implementation/bmad-code-review/customize.toml new file mode 100644 index 000000000..ebab1c67d --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-code-review/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-code-review/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-code-review/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-code-review/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-correct-course/SKILL.md b/src/bmm-skills/4-implementation/bmad-correct-course/SKILL.md index 021c715f8..18f1be4a6 100644 --- a/src/bmm-skills/4-implementation/bmad-correct-course/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-correct-course/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-correct-course/customize.toml b/src/bmm-skills/4-implementation/bmad-correct-course/customize.toml new file mode 100644 index 000000000..ce109a6af --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-correct-course/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-correct-course/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-correct-course/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-correct-course/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-create-story/SKILL.md b/src/bmm-skills/4-implementation/bmad-create-story/SKILL.md index 66119b062..a0b706751 100644 --- a/src/bmm-skills/4-implementation/bmad-create-story/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-create-story/SKILL.md @@ -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]"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-create-story/customize.toml b/src/bmm-skills/4-implementation/bmad-create-story/customize.toml new file mode 100644 index 000000000..7ff4da907 --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-create-story/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-create-story/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-create-story/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-create-story/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-dev-story/SKILL.md b/src/bmm-skills/4-implementation/bmad-dev-story/SKILL.md index 0eb505cc7..de026eaab 100644 --- a/src/bmm-skills/4-implementation/bmad-dev-story/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-dev-story/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-dev-story/customize.toml b/src/bmm-skills/4-implementation/bmad-dev-story/customize.toml new file mode 100644 index 000000000..b680dd091 --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-dev-story/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-dev-story/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-dev-story/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-dev-story/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md b/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md index 5235f7b6c..e2ef13a51 100644 --- a/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/SKILL.md @@ -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]"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/customize.toml b/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/customize.toml new file mode 100644 index 000000000..47ddc3e61 --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-qa-generate-e2e-tests/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md b/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md index b2f0df476..879aaf451 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md @@ -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.' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/customize.toml b/src/bmm-skills/4-implementation/bmad-quick-dev/customize.toml new file mode 100644 index 000000000..be3ff5fcf --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-quick-dev/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md b/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md index bdc2b6d2a..b7be3ea93 100644 --- a/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-retrospective/SKILL.md @@ -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]"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-retrospective/customize.toml b/src/bmm-skills/4-implementation/bmad-retrospective/customize.toml new file mode 100644 index 000000000..407199a56 --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-retrospective/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-retrospective/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-retrospective/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-retrospective/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md b/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md index 85783cf00..da28eb192 100644 --- a/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-sprint-planning/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-sprint-planning/customize.toml b/src/bmm-skills/4-implementation/bmad-sprint-planning/customize.toml new file mode 100644 index 000000000..d7bc603f1 --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-sprint-planning/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-sprint-planning/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-sprint-planning/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-sprint-planning/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md b/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md index 3a15968e8..c6ee78786 100644 --- a/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-sprint-status/SKILL.md @@ -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"' --- +## 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. + +## 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. diff --git a/src/bmm-skills/4-implementation/bmad-sprint-status/customize.toml b/src/bmm-skills/4-implementation/bmad-sprint-status/customize.toml new file mode 100644 index 000000000..72ad9ac06 --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-sprint-status/customize.toml @@ -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 = "" diff --git a/src/bmm-skills/4-implementation/bmad-sprint-status/scripts/resolve-customization.py b/src/bmm-skills/4-implementation/bmad-sprint-status/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-sprint-status/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-advanced-elicitation/SKILL.md b/src/core-skills/bmad-advanced-elicitation/SKILL.md index 98459cb7c..20c13ad02 100644 --- a/src/core-skills/bmad-advanced-elicitation/SKILL.md +++ b/src/core-skills/bmad-advanced-elicitation/SKILL.md @@ -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.' --- +## 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 **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 2. Show the improvements made 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. diff --git a/src/core-skills/bmad-advanced-elicitation/customize.toml b/src/core-skills/bmad-advanced-elicitation/customize.toml new file mode 100644 index 000000000..0343456ae --- /dev/null +++ b/src/core-skills/bmad-advanced-elicitation/customize.toml @@ -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 = "" diff --git a/src/core-skills/bmad-advanced-elicitation/scripts/resolve-customization.py b/src/core-skills/bmad-advanced-elicitation/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-advanced-elicitation/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-brainstorming/SKILL.md b/src/core-skills/bmad-brainstorming/SKILL.md index 865b476cc..5bfd3aaa0 100644 --- a/src/core-skills/bmad-brainstorming/SKILL.md +++ b/src/core-skills/bmad-brainstorming/SKILL.md @@ -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.' --- +## 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. + +## 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. diff --git a/src/core-skills/bmad-brainstorming/customize.toml b/src/core-skills/bmad-brainstorming/customize.toml new file mode 100644 index 000000000..9bd634e8c --- /dev/null +++ b/src/core-skills/bmad-brainstorming/customize.toml @@ -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 = "" diff --git a/src/core-skills/bmad-brainstorming/scripts/resolve-customization.py b/src/core-skills/bmad-brainstorming/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-brainstorming/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-distillator/SKILL.md b/src/core-skills/bmad-distillator/SKILL.md index 57c44d0c9..cb413017e 100644 --- a/src/core-skills/bmad-distillator/SKILL.md +++ b/src/core-skills/bmad-distillator/SKILL.md @@ -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'. --- +## 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 ## Overview @@ -174,4 +183,11 @@ 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. -6. **Clean up** — delete the temporary reconstruction files after the report is generated. \ No newline at end of file +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. diff --git a/src/core-skills/bmad-distillator/customize.toml b/src/core-skills/bmad-distillator/customize.toml new file mode 100644 index 000000000..6f3b6305e --- /dev/null +++ b/src/core-skills/bmad-distillator/customize.toml @@ -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 = "" diff --git a/src/core-skills/bmad-distillator/scripts/resolve-customization.py b/src/core-skills/bmad-distillator/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-distillator/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-editorial-review-prose/SKILL.md b/src/core-skills/bmad-editorial-review-prose/SKILL.md index 3498f925e..df13650b8 100644 --- a/src/core-skills/bmad-editorial-review-prose/SKILL.md +++ b/src/core-skills/bmad-editorial-review-prose/SKILL.md @@ -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' --- +## 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 **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. - **reader_type** (optional, default: `humans`) — `humans` for standard editorial, `llm` for precision focus - ## PRINCIPLES 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. - ## STEPS ### 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" | | 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 with error if content is empty or fewer than 3 words - 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) + +## 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. diff --git a/src/core-skills/bmad-editorial-review-prose/customize.toml b/src/core-skills/bmad-editorial-review-prose/customize.toml new file mode 100644 index 000000000..2e88bcbd3 --- /dev/null +++ b/src/core-skills/bmad-editorial-review-prose/customize.toml @@ -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 = "" diff --git a/src/core-skills/bmad-editorial-review-prose/scripts/resolve-customization.py b/src/core-skills/bmad-editorial-review-prose/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-editorial-review-prose/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-editorial-review-structure/SKILL.md b/src/core-skills/bmad-editorial-review-structure/SKILL.md index c93183148..ae3d1a164 100644 --- a/src/core-skills/bmad-editorial-review-structure/SKILL.md +++ b/src/core-skills/bmad-editorial-review-structure/SKILL.md @@ -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' --- +## 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 **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 reader_type is not "humans" or "llm" - 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. diff --git a/src/core-skills/bmad-editorial-review-structure/customize.toml b/src/core-skills/bmad-editorial-review-structure/customize.toml new file mode 100644 index 000000000..1b4e1242d --- /dev/null +++ b/src/core-skills/bmad-editorial-review-structure/customize.toml @@ -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 = "" diff --git a/src/core-skills/bmad-editorial-review-structure/scripts/resolve-customization.py b/src/core-skills/bmad-editorial-review-structure/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-editorial-review-structure/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-help/SKILL.md b/src/core-skills/bmad-help/SKILL.md index e829543cf..d95bee2cc 100644 --- a/src/core-skills/bmad-help/SKILL.md +++ b/src/core-skills/bmad-help/SKILL.md @@ -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.' --- +## 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 ## Purpose @@ -73,3 +82,10 @@ For each recommended item, present: - Recommend running each skill in a **fresh context window** - 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 + +## 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. diff --git a/src/core-skills/bmad-help/customize.toml b/src/core-skills/bmad-help/customize.toml new file mode 100644 index 000000000..0ad9704b2 --- /dev/null +++ b/src/core-skills/bmad-help/customize.toml @@ -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 = "" diff --git a/src/core-skills/bmad-help/scripts/resolve-customization.py b/src/core-skills/bmad-help/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-help/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-index-docs/SKILL.md b/src/core-skills/bmad-index-docs/SKILL.md index c92935b71..5d14a3f63 100644 --- a/src/core-skills/bmad-index-docs/SKILL.md +++ b/src/core-skills/bmad-index-docs/SKILL.md @@ -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' --- +## 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 **Goal:** Generate or update an index.md to reference all docs in a target folder. - ## EXECUTION ### 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 - ## OUTPUT FORMAT ```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 ``` - ## HALT CONDITIONS - HALT if target directory does not exist or is inaccessible - HALT if user does not have write permissions to create index.md - ## VALIDATION - 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) - Sort alphabetically within groups - 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. diff --git a/src/core-skills/bmad-index-docs/customize.toml b/src/core-skills/bmad-index-docs/customize.toml new file mode 100644 index 000000000..449cbbd2d --- /dev/null +++ b/src/core-skills/bmad-index-docs/customize.toml @@ -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 = "" diff --git a/src/core-skills/bmad-index-docs/scripts/resolve-customization.py b/src/core-skills/bmad-index-docs/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-index-docs/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-party-mode/SKILL.md b/src/core-skills/bmad-party-mode/SKILL.md index 9f451d821..2cf8185cc 100644 --- a/src/core-skills/bmad-party-mode/SKILL.md +++ b/src/core-skills/bmad-party-mode/SKILL.md @@ -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.' --- +## 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 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 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. diff --git a/src/core-skills/bmad-party-mode/customize.toml b/src/core-skills/bmad-party-mode/customize.toml new file mode 100644 index 000000000..c8f8e4cae --- /dev/null +++ b/src/core-skills/bmad-party-mode/customize.toml @@ -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 = "" diff --git a/src/core-skills/bmad-party-mode/scripts/resolve-customization.py b/src/core-skills/bmad-party-mode/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-party-mode/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-review-adversarial-general/SKILL.md b/src/core-skills/bmad-review-adversarial-general/SKILL.md index ae75b7caa..659fcb07d 100644 --- a/src/core-skills/bmad-review-adversarial-general/SKILL.md +++ b/src/core-skills/bmad-review-adversarial-general/SKILL.md @@ -3,6 +3,15 @@ name: bmad-review-adversarial-general description: 'Perform a Cynical Review and produce a findings report. Use when the user requests a critical review of something' --- +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python ./scripts/resolve-customization.py bmad-review-adversarial-general --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. + # Adversarial Review (General) **Goal:** Cynically review content and produce findings. @@ -13,7 +22,6 @@ description: 'Perform a Cynical Review and produce a findings report. Use when t - **content** — Content to review: diff, spec, story, doc, or any artifact - **also_consider** (optional) — Areas to keep in mind during review alongside normal adversarial analysis - ## EXECUTION ### Step 1: Receive Content @@ -30,8 +38,14 @@ Review with extreme skepticism — assume problems exist. Find at least ten issu Output findings as a Markdown list (descriptions only). - ## HALT CONDITIONS - HALT if zero findings — this is suspicious, re-analyze or ask for guidance - HALT if content is empty or unreadable + +## Post-Workflow Customization + +After the workflow completes, resolve `inject.after` from customization: +Run: `python ./scripts/resolve-customization.py bmad-review-adversarial-general --key inject.after` + +If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. diff --git a/src/core-skills/bmad-review-adversarial-general/customize.toml b/src/core-skills/bmad-review-adversarial-general/customize.toml new file mode 100644 index 000000000..50d72f83b --- /dev/null +++ b/src/core-skills/bmad-review-adversarial-general/customize.toml @@ -0,0 +1,27 @@ +# ────────────────────────────────────────────────────────────────── +# Customization Defaults: bmad-review-adversarial-general +# 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-review-adversarial-general.toml (team/org, committed to git) +# _bmad/customizations/bmad-review-adversarial-general.user.toml (personal, gitignored) +# 2. Copy just the fields you want to override into your file. +# Unmentioned fields inherit from this defaults file. +# 3. For array fields (like additional_resources), include the +# complete array you want -- arrays replace, not append. +# ────────────────────────────────────────────────────────────────── + +# Additional resource files loaded into workflow context on activation. +# Paths are relative to {project-root}. +additional_resources = [] + +# ────────────────────────────────────────────────────────────────── +# Injected prompts - content woven into the workflow's context. +# 'before' loads before the workflow begins. +# 'after' loads after the workflow completes (pre-finalize). +# ────────────────────────────────────────────────────────────────── +[inject] +before = "" +after = "" diff --git a/src/core-skills/bmad-review-adversarial-general/scripts/resolve-customization.py b/src/core-skills/bmad-review-adversarial-general/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-review-adversarial-general/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-review-edge-case-hunter/SKILL.md b/src/core-skills/bmad-review-edge-case-hunter/SKILL.md index 9bc9984d1..6e497a53b 100644 --- a/src/core-skills/bmad-review-edge-case-hunter/SKILL.md +++ b/src/core-skills/bmad-review-edge-case-hunter/SKILL.md @@ -3,6 +3,15 @@ name: bmad-review-edge-case-hunter description: 'Walk every branching path and boundary condition in content, report only unhandled edge cases. Orthogonal to adversarial review - method-driven not attitude-driven. Use when you need exhaustive edge-case analysis of code, specs, or diffs.' --- +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python ./scripts/resolve-customization.py bmad-review-edge-case-hunter --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. + # Edge Case Hunter Review **Goal:** You are a pure path tracer. Never comment on whether code is good or bad; only list missing handling. @@ -18,7 +27,6 @@ Ignore the rest of the codebase unless the provided content explicitly reference **Your method is exhaustive path enumeration — mechanically walk every branch, not hunt by intuition. Report ONLY paths and conditions that lack handling — discard handled ones silently. Do NOT editorialize or add filler — findings only.** - ## EXECUTION ### Step 1: Receive Content @@ -45,7 +53,6 @@ Ignore the rest of the codebase unless the provided content explicitly reference Output findings as a JSON array following the Output Format specification exactly. - ## OUTPUT FORMAT Return ONLY a valid JSON array of objects. Each object must contain exactly these four fields and nothing else: @@ -61,7 +68,13 @@ Return ONLY a valid JSON array of objects. Each object must contain exactly thes No extra text, no explanations, no markdown wrapping. An empty array `[]` is valid when no unhandled paths are found. - ## HALT CONDITIONS - If content is empty or cannot be decoded as text, return `[{"location":"N/A","trigger_condition":"Input empty or undecodable","guard_snippet":"Provide valid content to review","potential_consequence":"Review skipped — no analysis performed"}]` and stop + +## Post-Workflow Customization + +After the workflow completes, resolve `inject.after` from customization: +Run: `python ./scripts/resolve-customization.py bmad-review-edge-case-hunter --key inject.after` + +If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. diff --git a/src/core-skills/bmad-review-edge-case-hunter/customize.toml b/src/core-skills/bmad-review-edge-case-hunter/customize.toml new file mode 100644 index 000000000..1fa8202c1 --- /dev/null +++ b/src/core-skills/bmad-review-edge-case-hunter/customize.toml @@ -0,0 +1,27 @@ +# ────────────────────────────────────────────────────────────────── +# Customization Defaults: bmad-review-edge-case-hunter +# 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-review-edge-case-hunter.toml (team/org, committed to git) +# _bmad/customizations/bmad-review-edge-case-hunter.user.toml (personal, gitignored) +# 2. Copy just the fields you want to override into your file. +# Unmentioned fields inherit from this defaults file. +# 3. For array fields (like additional_resources), include the +# complete array you want -- arrays replace, not append. +# ────────────────────────────────────────────────────────────────── + +# Additional resource files loaded into workflow context on activation. +# Paths are relative to {project-root}. +additional_resources = [] + +# ────────────────────────────────────────────────────────────────── +# Injected prompts - content woven into the workflow's context. +# 'before' loads before the workflow begins. +# 'after' loads after the workflow completes (pre-finalize). +# ────────────────────────────────────────────────────────────────── +[inject] +before = "" +after = "" diff --git a/src/core-skills/bmad-review-edge-case-hunter/scripts/resolve-customization.py b/src/core-skills/bmad-review-edge-case-hunter/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-review-edge-case-hunter/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main() diff --git a/src/core-skills/bmad-shard-doc/SKILL.md b/src/core-skills/bmad-shard-doc/SKILL.md index 4945cff4c..1f43c53bf 100644 --- a/src/core-skills/bmad-shard-doc/SKILL.md +++ b/src/core-skills/bmad-shard-doc/SKILL.md @@ -3,6 +3,15 @@ name: bmad-shard-doc description: 'Splits large markdown documents into smaller, organized files based on level 2 (default) sections. Use if the user says perform shard document' --- +## Resolve Customization + +Resolve `inject` and `additional_resources` from customization: +Run: `python ./scripts/resolve-customization.py bmad-shard-doc --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. + # Shard Document **Goal:** Split large markdown documents into smaller, organized files based on level 2 sections using `npx @kayvan/markdown-tree-parser`. @@ -103,3 +112,10 @@ Present user with options for the original document: ## HALT CONDITIONS - HALT if npx command fails or produces no output files + +## Post-Workflow Customization + +After the workflow completes, resolve `inject.after` from customization: +Run: `python ./scripts/resolve-customization.py bmad-shard-doc --key inject.after` + +If resolved `inject.after` is not empty, incorporate its content as a final checklist or validation gate. diff --git a/src/core-skills/bmad-shard-doc/customize.toml b/src/core-skills/bmad-shard-doc/customize.toml new file mode 100644 index 000000000..7452a6f83 --- /dev/null +++ b/src/core-skills/bmad-shard-doc/customize.toml @@ -0,0 +1,27 @@ +# ────────────────────────────────────────────────────────────────── +# Customization Defaults: bmad-shard-doc +# 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-shard-doc.toml (team/org, committed to git) +# _bmad/customizations/bmad-shard-doc.user.toml (personal, gitignored) +# 2. Copy just the fields you want to override into your file. +# Unmentioned fields inherit from this defaults file. +# 3. For array fields (like additional_resources), include the +# complete array you want -- arrays replace, not append. +# ────────────────────────────────────────────────────────────────── + +# Additional resource files loaded into workflow context on activation. +# Paths are relative to {project-root}. +additional_resources = [] + +# ────────────────────────────────────────────────────────────────── +# Injected prompts - content woven into the workflow's context. +# 'before' loads before the workflow begins. +# 'after' loads after the workflow completes (pre-finalize). +# ────────────────────────────────────────────────────────────────── +[inject] +before = "" +after = "" diff --git a/src/core-skills/bmad-shard-doc/scripts/resolve-customization.py b/src/core-skills/bmad-shard-doc/scripts/resolve-customization.py new file mode 100755 index 000000000..b4c6aaece --- /dev/null +++ b/src/core-skills/bmad-shard-doc/scripts/resolve-customization.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# /// script +# requires-python = ">=3.11" +# /// +"""Resolve customization for a BMad skill using three-layer TOML merge. + +Reads customization from three layers (highest priority first): + 1. {project-root}/_bmad/customizations/{name}.user.toml (personal, gitignored) + 2. {project-root}/_bmad/customizations/{name}.toml (team/org, committed) + 3. ./customize.toml (skill defaults) + +Outputs merged JSON to stdout. Errors go to stderr. + +Usage: + python ./scripts/resolve-customization.py {skill-name} + python ./scripts/resolve-customization.py {skill-name} --key persona + python ./scripts/resolve-customization.py {skill-name} --key persona.displayName --key inject +""" + +from __future__ import annotations + +import argparse +import json +import os +import sys +import tomllib +from pathlib import Path +from typing import Any + + +def find_project_root(start: Path) -> Path | None: + """Walk up from *start* looking for a directory containing ``_bmad/`` or ``.git``.""" + current = start.resolve() + while True: + if (current / "_bmad").is_dir() or (current / ".git").exists(): + return current + parent = current.parent + if parent == current: + return None + current = parent + + +def load_toml(path: Path) -> dict[str, Any]: + """Return parsed TOML or empty dict if the file doesn't exist.""" + if not path.is_file(): + return {} + try: + with open(path, "rb") as f: + return tomllib.load(f) + except Exception as exc: + print(f"warning: failed to parse {path}: {exc}", file=sys.stderr) + return {} + + +# --------------------------------------------------------------------------- +# Merge helpers +# --------------------------------------------------------------------------- + +def _is_menu_array(value: Any) -> bool: + """True when *value* looks like a ``[[menu]]`` array of tables with ``code`` keys.""" + return ( + isinstance(value, list) + and len(value) > 0 + and isinstance(value[0], dict) + and "code" in value[0] + ) + + +def merge_menu(base: list[dict], override: list[dict]) -> list[dict]: + """Merge-by-code: matching codes replace; new codes append.""" + result_by_code: dict[str, dict] = {item["code"]: dict(item) for item in base} + for item in override: + result_by_code[item["code"]] = dict(item) + return list(result_by_code.values()) + + +def deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]: + """Recursively merge *override* into *base*. + + Rules: + - Tables (dicts): sparse override -- recurse, unmentioned keys kept. + - ``[[menu]]`` arrays (items with ``code`` key): merge-by-code. + - All other arrays: atomic replace. + - Scalars: override wins. + """ + merged = dict(base) + for key, over_val in override.items(): + base_val = merged.get(key) + + if isinstance(over_val, dict) and isinstance(base_val, dict): + merged[key] = deep_merge(base_val, over_val) + elif _is_menu_array(over_val) and _is_menu_array(base_val): + merged[key] = merge_menu(base_val, over_val) + else: + merged[key] = over_val + + return merged + + +# --------------------------------------------------------------------------- +# Key extraction +# --------------------------------------------------------------------------- + +def extract_key(data: dict[str, Any], dotted_key: str) -> Any: + """Retrieve a value by dotted path (e.g. ``persona.displayName``).""" + parts = dotted_key.split(".") + current: Any = data + for part in parts: + if isinstance(current, dict) and part in current: + current = current[part] + else: + return None + return current + + +# --------------------------------------------------------------------------- +# Main +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Resolve BMad skill customization (three-layer TOML merge).", + epilog=( + "Resolution priority: user.toml > team.toml > skill defaults.\n" + "Output is JSON. Use --key to request specific fields (JIT resolution)." + ), + ) + parser.add_argument( + "skill_name", + help="Skill identifier (e.g. bmad-agent-pm, bmad-product-brief)", + ) + parser.add_argument( + "--key", + action="append", + dest="keys", + metavar="FIELD", + help="Dotted field path to resolve (repeatable). Omit for full dump.", + ) + args = parser.parse_args() + + # Locate the skill's own customize.toml (one level up from scripts/) + script_dir = Path(__file__).resolve().parent + skill_dir = script_dir.parent + defaults_path = skill_dir / "customize.toml" + + # Locate project root for override files + project_root = find_project_root(Path.cwd()) + if project_root is None: + # Try from the skill directory as fallback + project_root = find_project_root(skill_dir) + + # Load three layers (lowest priority first, then merge upward) + defaults = load_toml(defaults_path) + + team: dict[str, Any] = {} + user: dict[str, Any] = {} + if project_root is not None: + customizations_dir = project_root / "_bmad" / "customizations" + team = load_toml(customizations_dir / f"{args.skill_name}.toml") + user = load_toml(customizations_dir / f"{args.skill_name}.user.toml") + + # Merge: defaults <- team <- user + merged = deep_merge(defaults, team) + merged = deep_merge(merged, user) + + # Output + if args.keys: + result = {} + for key in args.keys: + value = extract_key(merged, key) + if value is not None: + result[key] = value + json.dump(result, sys.stdout, indent=2, ensure_ascii=False) + else: + json.dump(merged, sys.stdout, indent=2, ensure_ascii=False) + + # Ensure trailing newline for clean terminal output + print() + + +if __name__ == "__main__": + main()