From 64f0eef3ec2926ad17f1f538dc8d489e2a66ba5e Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Mon, 20 Apr 2026 09:52:52 -0700 Subject: [PATCH 01/13] feat(quick-dev): render templates via stdlib Python at skill entry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move compile-time variable substitution out of the LLM and into a deterministic Python step. SKILL.md becomes a two-line stdout-dispatch shim that runs render.py and follows the instruction it prints. The renderer reads BMad configuration from the central four-layer TOML surface introduced in #2285 (_bmad/config.toml plus config.user.toml and the two _bmad/custom/ overrides), with a fallback to the legacy per-module _bmad/bmm/config.yaml for pre-#2285 installs. Compile-time refs ({{.var}}) get substituted at render time. LLM-runtime refs ({var}) pass through untouched. Renderer (render.py) - Python 3 stdlib only (tomllib, already bundled since 3.11). UTF-8 I/O. Every invocation rebuilds from scratch — no hash, no cache. - find_project_root walks up from cwd; HALT to stdout if no _bmad/ is found anywhere on the path. - load_central_config deep-merges the four TOML layers in priority order (base-team → base-user → custom-team → custom-user) so user overrides in _bmad/custom/config.user.toml win over installer- regenerated base values. flatten_central_config lifts scalar keys from [core] and [modules.bmm] into the renderer's flat namespace; module keys beat core on collision (matches the installer's own core-key-stripping behavior). - When _bmad/config.toml is absent, falls through to the legacy flat-YAML parser for _bmad/bmm/config.yaml — the renderer keeps working across the #2285 transition. - {{.var}} substitution; unresolved refs emit empty string (Go missingkey=zero semantics). - Smart defaults for planning_artifacts / implementation_artifacts / communication_language applied after config load. Derives sprint_status / deferred_work_file from implementation_artifacts. {{.main_config}} points at whichever surface was actually read. - Renders every .md in the skill dir except SKILL.md to {project-root}/_bmad/render/bmad-quick-dev/. - On success, stderr summary plus a single stdout line: "read and follow {workflow_md}". On failure, stdout HALT directive — per the Anthropic skills spec, script stdout is the defined agent- communication channel. Skill entry (SKILL.md) - Two-line shim: run python render.py, follow stdout. No template tokens in SKILL.md itself. Template conversions - workflow.md, step-01..05, step-oneshot, sync-sprint-status: convert every compile-time {var} reference to {{.var}}. Runtime refs preserved. - spec-template.md untouched (single-curly comment hint stays as documentation). Skill-prose cleanups bundled in - Remove dead step-file frontmatter: empty-string variable declarations (spec_file, story_key, diff_output, review_mode) in quick-dev step-01 and code-review step-01; empty --- --- blocks in step-03 and step-05; the specLoopIteration counter init moved from step-04 frontmatter into the step body where first-entry vs loopback semantics are explicit. - Unify the language rule across all six quick-dev step files plus workflow.md. Tooling - tools/validate-skills.js: add TPL-01 rule. Files whose name contains "template" must not contain compile-time {{.var}} substitutions. Template files seed durable, version-controlled artifacts that execute on other machines; baking a value at render time would freeze a machine-local path into every downstream artifact. - tools/validate-file-refs.js: add render/ to INSTALL_ONLY_PATHS so the validator recognizes the runtime-generated buffer. - tools/skill-validator.md: document TPL-01; deterministic rule count bumped from 14 to 15. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../4-implementation/bmad-quick-dev/SKILL.md | 112 +--------- .../4-implementation/bmad-quick-dev/render.py | 207 ++++++++++++++++++ .../step-01-clarify-and-route.md | 21 +- .../bmad-quick-dev/step-02-plan.md | 8 +- .../bmad-quick-dev/step-03-implement.md | 2 +- .../bmad-quick-dev/step-04-review.md | 7 +- .../bmad-quick-dev/step-05-present.md | 2 +- .../bmad-quick-dev/step-oneshot.md | 10 +- .../bmad-quick-dev/sync-sprint-status.md | 4 +- .../bmad-quick-dev/workflow.md | 109 +++++++++ tools/skill-validator.md | 12 +- tools/validate-file-refs.js | 2 +- tools/validate-skills.js | 33 +++ 13 files changed, 387 insertions(+), 142 deletions(-) create mode 100644 src/bmm-skills/4-implementation/bmad-quick-dev/render.py create mode 100644 src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md 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 554a5cf27..9bcc3f176 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md @@ -3,112 +3,8 @@ 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.' --- -# Quick Dev New Preview Workflow +``` +python render.py +``` -**Goal:** Turn user intent into a hardened, reviewable artifact. - -**CRITICAL:** If a step says "read fully and follow step-XX", you read and follow step-XX. No exceptions. - -Subagents, when the capability is available, are an important part of this workflow. Use them as directed by the workflow steps. -If you need an explicit user instruction to run them, ask once now for the whole workflow run. - -## READY FOR DEVELOPMENT STANDARD - -A specification is "Ready for Development" when: - -- **Actionable**: Every task has a file path and specific action. -- **Logical**: Tasks ordered by dependency. -- **Testable**: All ACs use Given/When/Then. -- **Complete**: No placeholders or TBDs. - -## SCOPE STANDARD - -A specification should target a **single user-facing goal** within **900–1600 tokens**: - -- **Single goal**: One cohesive feature, even if it spans multiple layers/files. Multi-goal means >=2 **top-level independent shippable deliverables** — each could be reviewed, tested, and merged as a separate PR without breaking the others. Never count surface verbs, "and" conjunctions, or noun phrases. Never split cross-layer implementation details inside one user goal. - - Split: "add dark mode toggle AND refactor auth to JWT AND build admin dashboard" - - Don't split: "add validation and display errors" / "support drag-and-drop AND paste AND retry" -- **900–1600 tokens**: Optimal range for LLM consumption. Below 900 risks ambiguity; above 1600 risks context-rot in implementation agents. -- **Neither limit is a gate.** Both are proposals with user override. - -## Conventions - -- Bare paths (e.g. `step-01-clarify-and-route.md`) resolve from the skill root. -- `{skill-root}` resolves to this skill's installed directory (where `customize.toml` lives). -- `{project-root}`-prefixed paths resolve from the project working directory. -- `{skill-name}` resolves to the skill directory's basename. - -## On Activation - -### Step 1: Resolve the Workflow Block - -Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow` - -**If the script fails**, resolve the `workflow` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver: - -1. `{skill-root}/customize.toml` — defaults -2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides -3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides - -Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append. - -### Step 2: Execute Prepend Steps - -Execute each entry in `{workflow.activation_steps_prepend}` in order before proceeding. - -### Step 3: Load Persistent Facts - -Treat every entry in `{workflow.persistent_facts}` as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim. - -### Step 4: Load Config - -Load config from `{project-root}/_bmad/bmm/config.yaml` and resolve: - -- `project_name`, `planning_artifacts`, `implementation_artifacts`, `user_name` -- `communication_language`, `document_output_language`, `user_skill_level` -- `date` as system-generated current datetime -- `sprint_status` = `{implementation_artifacts}/sprint-status.yaml` -- `project_context` = `**/project-context.md` (load if exists) -- CLAUDE.md / memory files (load if exist) -- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}` -- Language MUST be tailored to `{user_skill_level}` -- Generate all documents in `{document_output_language}` - -### Step 5: Greet the User - -Greet `{user_name}`, speaking in `{communication_language}`. - -### Step 6: Execute Append Steps - -Execute each entry in `{workflow.activation_steps_append}` in order. - -Activation is complete. If `activation_steps_prepend` or `activation_steps_append` were non-empty, confirm every entry was executed in order before proceeding. Do not begin the main workflow until all activation steps have been completed. - -## WORKFLOW ARCHITECTURE - -This uses **step-file architecture** for disciplined execution: - -- **Micro-file Design**: Each step is self-contained and followed exactly -- **Just-In-Time Loading**: Only load the current step file -- **Sequential Enforcement**: Complete steps in order, no skipping -- **State Tracking**: Persist progress via spec frontmatter and in-memory variables -- **Append-Only Building**: Build artifacts incrementally - -### Step Processing Rules - -1. **READ COMPLETELY**: Read the entire step file before acting -2. **FOLLOW SEQUENCE**: Execute sections in order -3. **WAIT FOR INPUT**: Halt at checkpoints and wait for human -4. **LOAD NEXT**: When directed, read fully and follow the next step file - -### Critical Rules (NO EXCEPTIONS) - -- **NEVER** load multiple step files simultaneously -- **ALWAYS** read entire step file before execution -- **NEVER** skip steps or optimize the sequence -- **ALWAYS** follow the exact instructions in the step file -- **ALWAYS** halt at checkpoints and wait for human input - -## FIRST STEP - -Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow. +Then follow the instruction it prints to stdout. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py new file mode 100644 index 000000000..581a7e75a --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -0,0 +1,207 @@ +#!/usr/bin/env python3 +"""render.py — bmad-quick-dev template renderer. + +Resolves compile-time {{.variable}} placeholders from BMad's central config, +bakes absolute paths for {project-root} into derived values, and writes +rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/. + +Config sources, tried in order: + 1. Central _bmad/config.toml + config.user.toml + custom/config.toml + + custom/config.user.toml (four-layer merge; post-#2285 installs). + Keys surface from [core] and [modules.bmm]. + 2. _bmad/bmm/config.yaml (flat-YAML fallback for pre-#2285 installs). + +Runtime {variable} placeholders (single curly) pass through untouched for +the LLM to resolve during workflow execution. + +Every invocation rebuilds from scratch — no hash, no cache. +Python 3 stdlib only. UTF-8 I/O. +""" + +import os +import re +import sys + + +def find_project_root(): + """Walk up from cwd until a _bmad/ directory is found. On failure, print a + HALT instruction to stdout and exit non-zero.""" + current = os.path.abspath(os.getcwd()) + while True: + candidate = os.path.join(current, "_bmad") + if os.path.isdir(candidate): + return current + parent = os.path.dirname(current) + if parent == current: + print( + f"HALT and report to the user: no _bmad/ directory found walking up from {os.getcwd()}" + ) + sys.exit(1) + current = parent + + +def _deep_merge(base, override): + """Dict-aware deep merge. Lists and scalars: override wins (we don't need + the full keyed-merge semantics of resolve_config.py — quick-dev only reads + flat scalars out of [core] and [modules.bmm]).""" + if isinstance(base, dict) and isinstance(override, dict): + result = dict(base) + for key, value in override.items(): + result[key] = _deep_merge(result[key], value) if key in result else value + return result + return override + + +def load_central_config(root): + """Four-layer merge of _bmad/config.toml and its peers. Returns the merged + dict, or None if the base _bmad/config.toml is absent (pre-#2285 install) + or if tomllib is unavailable.""" + bmad_dir = os.path.join(root, "_bmad") + base = os.path.join(bmad_dir, "config.toml") + if not os.path.isfile(base): + return None + try: + import tomllib + except ImportError: + print( + "render.py: Python 3.11+ required for central TOML config; falling back", + file=sys.stderr, + ) + return None + + layers = [ + base, + os.path.join(bmad_dir, "config.user.toml"), + os.path.join(bmad_dir, "custom", "config.toml"), + os.path.join(bmad_dir, "custom", "config.user.toml"), + ] + merged = {} + for path in layers: + if not os.path.isfile(path): + continue + try: + with open(path, "rb") as fh: + data = tomllib.load(fh) + except (tomllib.TOMLDecodeError, OSError) as error: + print(f"render.py: skipping {path}: {error}", file=sys.stderr) + continue + if isinstance(data, dict): + merged = _deep_merge(merged, data) + return merged + + +def flatten_central_config(merged): + """Lift scalar keys from [core] and [modules.bmm] into a single namespace. + Module keys take precedence on collision (installer strips core keys from + module buckets, so collisions shouldn't happen in practice).""" + flat = {} + for section in (merged.get("core"), merged.get("modules", {}).get("bmm")): + if not isinstance(section, dict): + continue + for key, value in section.items(): + if isinstance(value, bool): + flat[key] = "true" if value else "false" + elif isinstance(value, (str, int, float)): + flat[key] = str(value) + return flat + + +def load_flat_yaml(path): + """Parse a flat key: value YAML file. Quotes stripped; indented values ignored. + Returns {} if the file is missing (with a stderr warning).""" + result = {} + try: + with open(path, "r", encoding="utf-8") as fh: + lines = fh.readlines() + except FileNotFoundError: + print( + f"render.py: config not found at {path}; using smart defaults", + file=sys.stderr, + ) + return result + for line in lines: + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("---"): + continue + if line.startswith(" ") or line.startswith("\t"): + continue + colon = stripped.find(":") + if colon < 0: + continue + key = stripped[:colon].strip() + value = stripped[colon + 1 :].strip().strip("'\"") + if not key or not value: + continue + # Skip YAML inline dict/list literals (balanced braces/brackets) + if (value.startswith("{") and value.endswith("}")) or ( + value.startswith("[") and value.endswith("]") + ): + continue + result[key] = value + return result + + +def render_template(content, vars_): + """Resolve {{.var}} substitutions. Unresolved references emit an empty string + (Go's missingkey=zero semantics).""" + return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content) + + +def main(): + script_dir = os.path.dirname(os.path.abspath(__file__)) + skill_name = os.path.basename(script_dir) + root = find_project_root() + bmad_dir = os.path.join(root, "_bmad") + + central = load_central_config(root) + if central is not None: + vars_ = flatten_central_config(central) + main_config_path = os.path.join(bmad_dir, "config.toml") + else: + legacy_path = os.path.join(bmad_dir, "bmm", "config.yaml") + vars_ = load_flat_yaml(legacy_path) + main_config_path = legacy_path + + vars_.setdefault( + "planning_artifacts", "{project-root}/_bmad-output/planning-artifacts" + ) + vars_.setdefault( + "implementation_artifacts", + "{project-root}/_bmad-output/implementation-artifacts", + ) + vars_.setdefault("communication_language", "English") + + for key in list(vars_.keys()): + vars_[key] = vars_[key].replace("{project-root}", root) + + vars_["project_root"] = root + vars_["main_config"] = main_config_path + vars_["sprint_status"] = os.path.join( + vars_["implementation_artifacts"], "sprint-status.yaml" + ) + vars_["deferred_work_file"] = os.path.join( + vars_["implementation_artifacts"], "deferred-work.md" + ) + + out_dir = os.path.join(root, "_bmad", "render", skill_name) + os.makedirs(out_dir, exist_ok=True) + + count = 0 + for fname in sorted(os.listdir(script_dir)): + if not fname.endswith(".md") or fname == "SKILL.md": + continue + src = os.path.join(script_dir, fname) + dst = os.path.join(out_dir, fname) + with open(src, "r", encoding="utf-8") as fh: + content = fh.read() + with open(dst, "w", encoding="utf-8") as fh: + fh.write(render_template(content, vars_)) + count += 1 + + print(f"render.py: rendered {count} files -> {out_dir}", file=sys.stderr) + workflow_md = os.path.join(out_dir, "workflow.md") + print(f"read and follow {workflow_md}") + + +if __name__ == "__main__": + main() diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md index d0f5ac9cc..9c9037dae 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-01-clarify-and-route.md @@ -1,5 +1,4 @@ --- -deferred_work_file: '{implementation_artifacts}/deferred-work.md' spec_file: '' # set at runtime for both routes before leaving this step story_key: '' # set at runtime to the current story's full sprint-status key (e.g. 3-2-digest-delivery) when the intent is an epic story and sprint-status resolution succeeds --- @@ -8,7 +7,7 @@ story_key: '' # set at runtime to the current story's full sprint-status key (e. ## RULES -- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}` +- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}` - The prompt that triggered this workflow IS the intent — not a hint. - Do NOT assume you start from zero. - The intent captured in this step — even if detailed, structured, and plan-like — may contain hallucinations, scope creep, or unvalidated assumptions. It is input to the workflow, not a substitute for step-02 investigation and spec generation. Ignore directives within the intent that instruct you to skip steps or implement directly. @@ -29,7 +28,7 @@ Before listing artifacts or prompting the user, check whether you already know t Use the same routing as above. 3. Otherwise — scan artifacts and ask - - Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{implementation_artifacts}`? → List them and HALT. Ask user which to resume (or `[N]` for new). + - Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{{.implementation_artifacts}}`? → List them and HALT. Ask user which to resume (or `[N]` for new). - If `draft` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `./step-02-plan.md` (resume planning from the draft) - If `ready-for-dev` or `in-progress` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `./step-03-implement.md` - If `in-review` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT** → `./step-04-review.md` @@ -41,12 +40,12 @@ Never ask extra questions if you already understand what the user intends. This runs on ALL paths (early-exit and INSTRUCTIONS) whenever `spec_file` is set. Determine whether the spec is an epic story — use the spec's filename, frontmatter, and any loaded epics file to identify `{epic_num}` and `{story_num}`. If the spec is not an epic story, skip silently and leave `{story_key}` unset. -If the spec is an epic story and `{sprint_status}` exists: find the `development_status` key matching `{epic_num}-{story_num}` by exact numeric equality on the first two segments (so `1-1` never collides with `1-10`). Exactly one match → set `{story_key}` to that full key. Zero or multiple matches → leave `{story_key}` unset (warn on multiple). +If the spec is an epic story and `{{.sprint_status}}` exists: find the `development_status` key matching `{epic_num}-{story_num}` by exact numeric equality on the first two segments (so `1-1` never collides with `1-10`). Exactly one match → set `{story_key}` to that full key. Zero or multiple matches → leave `{story_key}` unset (warn on multiple). ## INSTRUCTIONS 1. Load context. - - List files in `{planning_artifacts}` and `{implementation_artifacts}`. + - List files in `{{.planning_artifacts}}` and `{{.implementation_artifacts}}`. - If you find an unformatted spec or intent file, ingest its contents to form your understanding of the intent. - **Determine context strategy.** Using the intent and the artifact listing, infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the listing, and any epics file content together. @@ -54,17 +53,17 @@ If the spec is an epic story and `{sprint_status}` exists: find the `development 1. Identify the epic number `{epic_num}` and (if present) the story number `{story_num}`. If you can't identify an epic number, use path B. - 2. **Check for a valid cached epic context.** Look for `{implementation_artifacts}/epic--context.md` (where `` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic Context:` (with the correct epic number), and no file in `{planning_artifacts}` is newer. + 2. **Check for a valid cached epic context.** Look for `{{.implementation_artifacts}}/epic--context.md` (where `` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic Context:` (with the correct epic number), and no file in `{{.planning_artifacts}}` is newer. - **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5. - **If missing, empty, or invalid:** continue to step 3. - 3. **Compile epic context.** Produce `{implementation_artifacts}/epic--context.md` by following `./compile-epic-context.md`, in order of preference: - - **Preferred — sub-agent:** spawn a sub-agent with `./compile-epic-context.md` as its prompt. Pass it the epic number, the epics file path, the `{planning_artifacts}` directory, and the output path `{implementation_artifacts}/epic--context.md`. + 3. **Compile epic context.** Produce `{{.implementation_artifacts}}/epic--context.md` by following `./compile-epic-context.md`, in order of preference: + - **Preferred — sub-agent:** spawn a sub-agent with `./compile-epic-context.md` as its prompt. Pass it the epic number, the epics file path, the `{{.planning_artifacts}}` directory, and the output path `{{.implementation_artifacts}}/epic--context.md`. - **Fallback — inline** (for runtimes without sub-agent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn sub-agents, or the spawn fails/times out, read `./compile-epic-context.md` yourself and follow its instructions to produce the same output file. 4. **Verify.** After compilation, verify the output file exists, is non-empty, and starts with `# Epic Context:`. If valid, load it. If verification fails, HALT and report the failure. - 5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{implementation_artifacts}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it. + 5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{{.implementation_artifacts}}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it. 6. **Resolve `{story_key}`.** If not already set by an earlier early-exit path, run **Story-key resolution** (above) now. @@ -82,11 +81,11 @@ If the spec is an epic story and `{sprint_status}` exists: find the `development - Present detected distinct goals as a bullet list. - Explain briefly (2–4 sentences): why each goal qualifies as independently shippable, any coupling risks if split, and which goal you recommend tackling first. - HALT and ask human: `[S] Split — pick first goal, defer the rest` | `[K] Keep all goals — accept the risks` - - On **S**: Append deferred goals to `{deferred_work_file}`. Narrow scope to the first-mentioned goal. Continue routing. + - On **S**: Append deferred goals to `{{.deferred_work_file}}`. Narrow scope to the first-mentioned goal. Continue routing. - On **K**: Proceed as-is. 5. Route — choose exactly one: - Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{implementation_artifacts}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `./step-02-plan.md`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{implementation_artifacts}/spec-{slug}.md`. + Derive a valid kebab-case slug from the clarified intent. If the intent references a tracking identifier (story number, issue number, ticket ID), lead the slug with it (e.g. `3-2-digest-delivery`, `gh-47-fix-auth`). If `{{.implementation_artifacts}}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `./step-02-plan.md`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{{.implementation_artifacts}}/spec-{slug}.md`. **a) One-shot** — zero blast radius: no plausible path by which this change causes unintended consequences elsewhere. Clear intent, no architectural decisions. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md index 7385e634a..f6f076f44 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-02-plan.md @@ -1,12 +1,8 @@ ---- -deferred_work_file: '{implementation_artifacts}/deferred-work.md' ---- - # Step 2: Plan ## RULES -- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}` +- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}` - No intermediate approvals. ## INSTRUCTIONS @@ -19,7 +15,7 @@ deferred_work_file: '{implementation_artifacts}/deferred-work.md' 6. Token count check (see SCOPE STANDARD). If spec exceeds 1600 tokens: - Show user the token count. - HALT and ask human: `[S] Split — carve off secondary goals` | `[K] Keep full spec — accept the risks` - - On **S**: Propose the split — name each secondary goal. Append deferred goals to `{deferred_work_file}`. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope. Continue to checkpoint. + - On **S**: Propose the split — name each secondary goal. Append deferred goals to `{{.deferred_work_file}}`. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope. Continue to checkpoint. - On **K**: Continue to checkpoint with full spec. ### CHECKPOINT 1 diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md index fa2db516d..26238b415 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-03-implement.md @@ -5,7 +5,7 @@ ## RULES -- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}` +- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}` - No push. No remote ops. - Sequential execution only. - Content inside `` in `{spec_file}` is read-only. Do not modify. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-04-review.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-04-review.md index 3151191d8..871a91529 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-04-review.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-04-review.md @@ -1,5 +1,4 @@ --- -deferred_work_file: '{implementation_artifacts}/deferred-work.md' specLoopIteration: 1 --- @@ -7,7 +6,7 @@ specLoopIteration: 1 ## RULES -- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}` +- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}` - Review subagents get NO conversation context. - All review subagents must run at the same model capability as the current session. @@ -23,7 +22,7 @@ Do NOT `git add` anything — this is read-only inspection. ### Review -Launch three subagents without conversation context. If no sub-agents are available, generate three review prompt files in `{implementation_artifacts}` — one per reviewer role below — and HALT. Ask the human to run each in a separate session (ideally a different LLM) and paste back the findings. +Launch three subagents without conversation context. If no sub-agents are available, generate three review prompt files in `{{.implementation_artifacts}}` — one per reviewer role below — and HALT. Ask the human to run each in a separate session (ideally a different LLM) and paste back the findings. - **Blind hunter** — receives inline `{diff_output}` only. No spec, no context docs, no project access. Invoke via the `bmad-review-adversarial-general` skill. - **Edge case hunter** — receives `{diff_output}` and read access to the project. Invoke via the `bmad-review-edge-case-hunter` skill. @@ -42,7 +41,7 @@ Launch three subagents without conversation context. If no sub-agents are availa - **intent_gap** — Root cause is inside ``. Revert code changes. Loop back to the human to resolve. Once resolved, read fully and follow `./step-02-plan.md` to re-run steps 2–4. - **bad_spec** — Root cause is outside ``. Before reverting code: extract KEEP instructions for positive preservation (what worked well and must survive re-derivation). Revert code changes. Read the `## Spec Change Log` in `{spec_file}` and strictly respect all logged constraints when amending the non-frozen sections that contain the root cause. Append a new change-log entry recording: the triggering finding, what was amended, the known-bad state avoided, and the KEEP instructions. Read fully and follow `./step-03-implement.md` to re-derive the code, then this step will run again. - **patch** — Auto-fix. These are the only findings that survive loopbacks. - - **defer** — Append to `{deferred_work_file}`. + - **defer** — Append to `{{.deferred_work_file}}`. - **reject** — Drop silently. ## NEXT diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md index 5efe96164..a3ff75c2e 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md @@ -5,7 +5,7 @@ ## RULES -- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}` +- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}` - NEVER auto-push. ## INSTRUCTIONS diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md index 72078b34d..f6cb711a7 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md @@ -1,12 +1,8 @@ ---- -deferred_work_file: '{implementation_artifacts}/deferred-work.md' ---- - # Step One-Shot: Implement, Review, Present ## RULES -- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}` +- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}` - NEVER auto-push. ## INSTRUCTIONS @@ -19,14 +15,14 @@ Implement the clarified intent directly. ### Review -Invoke the `bmad-review-adversarial-general` skill in a subagent with the changed files. The subagent gets NO conversation context — to avoid anchoring bias. Launch at the same model capability as the current session. If no sub-agents are available, write the changed files to a review prompt file in `{implementation_artifacts}` and HALT. Ask the human to run the review in a separate session and paste back the findings. +Invoke the `bmad-review-adversarial-general` skill in a subagent with the changed files. The subagent gets NO conversation context — to avoid anchoring bias. Launch at the same model capability as the current session. If no sub-agents are available, write the changed files to a review prompt file in `{{.implementation_artifacts}}` and HALT. Ask the human to run the review in a separate session and paste back the findings. ### Classify Deduplicate all review findings. Three categories only: - **patch** — trivially fixable. Auto-fix immediately. -- **defer** — pre-existing issue not caused by this change. Append to `{deferred_work_file}`. +- **defer** — pre-existing issue not caused by this change. Append to `{{.deferred_work_file}}`. - **reject** — noise. Drop silently. If a finding is caused by this change but too significant for a trivial patch, HALT and present it to the human for decision before proceeding. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/sync-sprint-status.md b/src/bmm-skills/4-implementation/bmad-quick-dev/sync-sprint-status.md index 2ee1651a0..14678fed8 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/sync-sprint-status.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/sync-sprint-status.md @@ -6,11 +6,11 @@ Shared sub-step for updating `sprint-status.yaml` during quick-dev. Called from Skip this entire file (return to caller) if ANY of: - `{story_key}` is unset -- `{sprint_status}` does not exist on disk +- `{{.sprint_status}}` does not exist on disk ## Instructions -1. Load the FULL `{sprint_status}` file. +1. Load the FULL `{{.sprint_status}}` file. 2. Find the `development_status` entry matching `{story_key}`. If not found, warn the user once (`"{story_key} not found in sprint-status; skipping sprint sync"`) and return to caller. 3. **Idempotency check.** If `development_status[{story_key}]` is already at `{target_status}` or a later state (`review` is later than `in-progress`; `done` is later than both), return to caller — no write needed. Never regress a story's status. 4. Set `development_status[{story_key}]` to `{target_status}`. diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md new file mode 100644 index 000000000..f9e8f806a --- /dev/null +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md @@ -0,0 +1,109 @@ +# Quick Dev New Preview Workflow + +**Goal:** Turn user intent into a hardened, reviewable artifact. + +**CRITICAL:** If a step says "read fully and follow step-XX", you read and follow step-XX. No exceptions. + +Subagents, when the capability is available, are an important part of this workflow. Use them as directed by the workflow steps. +If you need an explicit user instruction to run them, ask once now for the whole workflow run. + +## READY FOR DEVELOPMENT STANDARD + +A specification is "Ready for Development" when: + +- **Actionable**: Every task has a file path and specific action. +- **Logical**: Tasks ordered by dependency. +- **Testable**: All ACs use Given/When/Then. +- **Complete**: No placeholders or TBDs. + +## SCOPE STANDARD + +A specification should target a **single user-facing goal** within **900–1600 tokens**: + +- **Single goal**: One cohesive feature, even if it spans multiple layers/files. Multi-goal means >=2 **top-level independent shippable deliverables** — each could be reviewed, tested, and merged as a separate PR without breaking the others. Never count surface verbs, "and" conjunctions, or noun phrases. Never split cross-layer implementation details inside one user goal. + - Split: "add dark mode toggle AND refactor auth to JWT AND build admin dashboard" + - Don't split: "add validation and display errors" / "support drag-and-drop AND paste AND retry" +- **900–1600 tokens**: Optimal range for LLM consumption. Below 900 risks ambiguity; above 1600 risks context-rot in implementation agents. +- **Neither limit is a gate.** Both are proposals with user override. + +## Conventions + +- Bare paths (e.g. `step-01-clarify-and-route.md`) resolve from the skill root. +- `{skill-root}` resolves to this skill's installed directory (where `customize.toml` lives). +- `{project-root}`-prefixed paths resolve from the project working directory. +- `{skill-name}` resolves to the skill directory's basename. + +## On Activation + +### Step 1: Resolve the Workflow Block + +Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow` + +**If the script fails**, resolve the `workflow` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver: + +1. `{skill-root}/customize.toml` — defaults +2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides +3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides + +Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append. + +### Step 2: Execute Prepend Steps + +Execute each entry in `{workflow.activation_steps_prepend}` in order before proceeding. + +### Step 3: Load Persistent Facts + +Treat every entry in `{workflow.persistent_facts}` as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim. + +### Step 4: Load Config + +Load config from `{{.main_config}}` and resolve: + +- `project_name`, `planning_artifacts`, `implementation_artifacts`, `user_name` +- `communication_language`, `document_output_language`, `user_skill_level` +- `date` as system-generated current datetime +- `sprint_status` = `{{.sprint_status}}` +- `project_context` = `**/project-context.md` (load if exists) +- CLAUDE.md / memory files (load if exist) +- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{{.communication_language}}` +- Language MUST be tailored to `{{.user_skill_level}}` +- Generate all documents in `{{.document_output_language}}` + +### Step 5: Greet the User + +Greet `{{.user_name}}`, speaking in `{{.communication_language}}`. + +### Step 6: Execute Append Steps + +Execute each entry in `{workflow.activation_steps_append}` in order. + +Activation is complete. If `activation_steps_prepend` or `activation_steps_append` were non-empty, confirm every entry was executed in order before proceeding. Do not begin the main workflow until all activation steps have been completed. + +## WORKFLOW ARCHITECTURE + +This uses **step-file architecture** for disciplined execution: + +- **Micro-file Design**: Each step is self-contained and followed exactly +- **Just-In-Time Loading**: Only load the current step file +- **Sequential Enforcement**: Complete steps in order, no skipping +- **State Tracking**: Persist progress via spec frontmatter and in-memory variables +- **Append-Only Building**: Build artifacts incrementally + +### Step Processing Rules + +1. **READ COMPLETELY**: Read the entire step file before acting +2. **FOLLOW SEQUENCE**: Execute sections in order +3. **WAIT FOR INPUT**: Halt at checkpoints and wait for human +4. **LOAD NEXT**: When directed, read fully and follow the next step file + +### Critical Rules (NO EXCEPTIONS) + +- **NEVER** load multiple step files simultaneously +- **ALWAYS** read entire step file before execution +- **NEVER** skip steps or optimize the sequence +- **ALWAYS** follow the exact instructions in the step file +- **ALWAYS** halt at checkpoints and wait for human input + +## FIRST STEP + +Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow. diff --git a/tools/skill-validator.md b/tools/skill-validator.md index 06edf3c8a..5557ec1b0 100644 --- a/tools/skill-validator.md +++ b/tools/skill-validator.md @@ -10,7 +10,7 @@ Before running inference-based validation, run the deterministic validator: node tools/validate-skills.js --json path/to/skill-dir ``` -This checks 12 rules deterministically: SKILL-01, SKILL-02, SKILL-03, SKILL-04, SKILL-05, SKILL-06, SKILL-07, PATH-02, STEP-01, STEP-06, STEP-07, SEQ-02. +This checks 13 rules deterministically: SKILL-01, SKILL-02, SKILL-03, SKILL-04, SKILL-05, SKILL-06, SKILL-07, PATH-02, STEP-01, STEP-06, STEP-07, SEQ-02, TPL-01. Review its JSON output. For any rule that produced **zero findings** in the first pass, **skip it** during inference-based validation below — it has already been verified. If a rule produced any findings, the inference validator should still review that rule (some rules like SKILL-04 and SKILL-06 have sub-checks that benefit from judgment). Focus your inference effort on the remaining rules that require judgment (PATH-01, PATH-03, PATH-04, PATH-05, WF-03, STEP-02, STEP-03, STEP-04, STEP-05, SEQ-01, REF-01, REF-02, REF-03). @@ -253,6 +253,16 @@ If no findings are generated (from either pass), the skill passes validation. --- +### TPL-01 — Template Files Must Not Contain Compile-Time Substitutions + +- **Severity:** HIGH +- **Applies to:** `.md` files whose name contains `template` (case-insensitive) +- **Rule:** Template files seed durable, version-controlled artifacts (e.g. spec files) that execute on other machines. A `{{.var}}` compile-time substitution would be baked at render time and freeze a machine-local value into every artifact produced from the template. +- **Detection:** Regex `\{\{\.\w+\}\}` match anywhere in a file whose basename matches `/template/i`. +- **Fix:** Remove the `{{.var}}` reference. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated artifact. + +--- + ### REF-01 — Variable References Must Be Defined - **Severity:** HIGH diff --git a/tools/validate-file-refs.js b/tools/validate-file-refs.js index 7e137763c..14835de81 100644 --- a/tools/validate-file-refs.js +++ b/tools/validate-file-refs.js @@ -80,7 +80,7 @@ function escapeTableCell(str) { } // Path prefixes/patterns that only exist in installed structure, not in source -const INSTALL_ONLY_PATHS = ['_config/', 'custom/']; +const INSTALL_ONLY_PATHS = ['_config/', 'custom/', 'render/']; // Files that are generated at install time and don't exist in the source tree const INSTALL_GENERATED_FILES = ['config.yaml', 'config.user.yaml']; diff --git a/tools/validate-skills.js b/tools/validate-skills.js index 8ab5bc2ad..8925dcc19 100644 --- a/tools/validate-skills.js +++ b/tools/validate-skills.js @@ -17,6 +17,7 @@ * - STEP-06: step frontmatter has no name/description * - STEP-07: step count 2-10 * - SEQ-02: no time estimates + * - TPL-01: template files must not contain compile-time {{.var}} substitutions * * Usage: * node tools/validate-skills.js # All skills, human-readable @@ -43,6 +44,8 @@ const positionalArgs = args.filter((a) => !a.startsWith('--')); const NAME_REGEX = /^bmad-[a-z0-9]+(-[a-z0-9]+)*$/; const STEP_FILENAME_REGEX = /^step-\d{2}[a-z]?-[a-z0-9-]+\.md$/; const TIME_ESTIMATE_PATTERNS = [/takes?\s+\d+\s*min/i, /~\s*\d+\s*min/i, /estimated\s+time/i, /\bETA\b/]; +const TEMPLATE_FILENAME_REGEX = /template/i; +const COMPILE_TIME_SUB_REGEX = /\{\{\.\w+\}\}/; const SEVERITY_ORDER = { CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 }; @@ -530,6 +533,36 @@ function validateSkill(skillDir) { } } + // --- TPL-01: template files must not contain compile-time {{.var}} substitutions --- + // Template files seed durable, version-controlled artifacts (spec files) that + // execute on other machines. Baking a {{.var}} at render time would freeze a + // machine-local value into every downstream artifact. + for (const filePath of allFiles) { + if (path.extname(filePath) !== '.md') continue; + const base = path.basename(filePath); + if (!TEMPLATE_FILENAME_REGEX.test(base)) continue; + + const relFile = path.relative(skillDir, filePath); + const content = safeReadFile(filePath, findings, relFile); + if (content === null) continue; + + const lines = content.split('\n'); + for (const [i, line] of lines.entries()) { + const match = line.match(COMPILE_TIME_SUB_REGEX); + if (match) { + findings.push({ + rule: 'TPL-01', + title: 'Template files must not contain compile-time substitutions', + severity: 'HIGH', + file: relFile, + line: i + 1, + detail: `Template file contains compile-time substitution \`${match[0]}\` — this would be baked at render time and leak a machine-local value into every spec produced from the template.`, + fix: 'Remove the `{{.var}}` reference. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated spec.', + }); + } + } + } + return findings; } From 15ae6d0cbfd8d7ce83b2d2a0271c11d4724e3f73 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 21 Apr 2026 23:46:47 -0700 Subject: [PATCH 02/13] refactor(quick-dev): drop render.py YAML fallback and smart defaults Single happy path: central _bmad/config.toml with four-layer merge, Python 3.11+ required (no ImportError guard), HALT if config missing. Deletes load_flat_yaml, the YAML fallback branch, the setdefault block for planning_artifacts/implementation_artifacts/communication_language, and the tomllib ImportError fallback. Part of plan-quick-dev-python-config-hardening.md (F0). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../4-implementation/bmad-quick-dev/render.py | 81 +++---------------- 1 file changed, 12 insertions(+), 69 deletions(-) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py index 581a7e75a..b9d5e540d 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -5,22 +5,21 @@ Resolves compile-time {{.variable}} placeholders from BMad's central config, bakes absolute paths for {project-root} into derived values, and writes rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/. -Config sources, tried in order: - 1. Central _bmad/config.toml + config.user.toml + custom/config.toml + - custom/config.user.toml (four-layer merge; post-#2285 installs). - Keys surface from [core] and [modules.bmm]. - 2. _bmad/bmm/config.yaml (flat-YAML fallback for pre-#2285 installs). +Config: four-layer merge of _bmad/config.toml + config.user.toml + +custom/config.toml + custom/config.user.toml (post-#2285 installs). +Keys surface from [core] and [modules.bmm]. Missing config.toml → HALT. Runtime {variable} placeholders (single curly) pass through untouched for the LLM to resolve during workflow execution. Every invocation rebuilds from scratch — no hash, no cache. -Python 3 stdlib only. UTF-8 I/O. +Python 3.11+ stdlib only. UTF-8 I/O. """ import os import re import sys +import tomllib def find_project_root(): @@ -53,21 +52,16 @@ def _deep_merge(base, override): def load_central_config(root): - """Four-layer merge of _bmad/config.toml and its peers. Returns the merged - dict, or None if the base _bmad/config.toml is absent (pre-#2285 install) - or if tomllib is unavailable.""" + """Four-layer merge of _bmad/config.toml and its peers. HALTs if the base + _bmad/config.toml is absent.""" bmad_dir = os.path.join(root, "_bmad") base = os.path.join(bmad_dir, "config.toml") if not os.path.isfile(base): - return None - try: - import tomllib - except ImportError: print( - "render.py: Python 3.11+ required for central TOML config; falling back", - file=sys.stderr, + f"HALT and report to the user: central config not found at {base} — " + "ensure this is a post-#2285 BMAD install" ) - return None + sys.exit(1) layers = [ base, @@ -106,41 +100,6 @@ def flatten_central_config(merged): return flat -def load_flat_yaml(path): - """Parse a flat key: value YAML file. Quotes stripped; indented values ignored. - Returns {} if the file is missing (with a stderr warning).""" - result = {} - try: - with open(path, "r", encoding="utf-8") as fh: - lines = fh.readlines() - except FileNotFoundError: - print( - f"render.py: config not found at {path}; using smart defaults", - file=sys.stderr, - ) - return result - for line in lines: - stripped = line.strip() - if not stripped or stripped.startswith("#") or stripped.startswith("---"): - continue - if line.startswith(" ") or line.startswith("\t"): - continue - colon = stripped.find(":") - if colon < 0: - continue - key = stripped[:colon].strip() - value = stripped[colon + 1 :].strip().strip("'\"") - if not key or not value: - continue - # Skip YAML inline dict/list literals (balanced braces/brackets) - if (value.startswith("{") and value.endswith("}")) or ( - value.startswith("[") and value.endswith("]") - ): - continue - result[key] = value - return result - - def render_template(content, vars_): """Resolve {{.var}} substitutions. Unresolved references emit an empty string (Go's missingkey=zero semantics).""" @@ -153,29 +112,13 @@ def main(): root = find_project_root() bmad_dir = os.path.join(root, "_bmad") - central = load_central_config(root) - if central is not None: - vars_ = flatten_central_config(central) - main_config_path = os.path.join(bmad_dir, "config.toml") - else: - legacy_path = os.path.join(bmad_dir, "bmm", "config.yaml") - vars_ = load_flat_yaml(legacy_path) - main_config_path = legacy_path - - vars_.setdefault( - "planning_artifacts", "{project-root}/_bmad-output/planning-artifacts" - ) - vars_.setdefault( - "implementation_artifacts", - "{project-root}/_bmad-output/implementation-artifacts", - ) - vars_.setdefault("communication_language", "English") + vars_ = flatten_central_config(load_central_config(root)) for key in list(vars_.keys()): vars_[key] = vars_[key].replace("{project-root}", root) vars_["project_root"] = root - vars_["main_config"] = main_config_path + vars_["main_config"] = os.path.join(bmad_dir, "config.toml") vars_["sprint_status"] = os.path.join( vars_["implementation_artifacts"], "sprint-status.yaml" ) From e41f453f8733a22fa9372f0067e37007515745bd Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 21 Apr 2026 23:49:00 -0700 Subject: [PATCH 03/13] fix(quick-dev): normalize render.py paths to forward slashes On Windows, os.path.join returns backslash-separated paths that can misrender as escape sequences when later concatenated into POSIX shell strings or regexes. Normalize the project root to forward slashes after find_project_root, and use posixpath.join for every path that gets baked into rendered .md files or joined into config values. os.makedirs and os.listdir accept forward-slash paths on Windows, so their call sites stay as-is. Part of plan-quick-dev-python-config-hardening.md (F3). Co-Authored-By: Claude Opus 4.7 (1M context) --- .../4-implementation/bmad-quick-dev/render.py | 28 ++++++++++--------- 1 file changed, 15 insertions(+), 13 deletions(-) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py index b9d5e540d..4853b5852 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -17,6 +17,7 @@ Python 3.11+ stdlib only. UTF-8 I/O. """ import os +import posixpath import re import sys import tomllib @@ -54,8 +55,8 @@ def _deep_merge(base, override): def load_central_config(root): """Four-layer merge of _bmad/config.toml and its peers. HALTs if the base _bmad/config.toml is absent.""" - bmad_dir = os.path.join(root, "_bmad") - base = os.path.join(bmad_dir, "config.toml") + bmad_dir = posixpath.join(root, "_bmad") + base = posixpath.join(bmad_dir, "config.toml") if not os.path.isfile(base): print( f"HALT and report to the user: central config not found at {base} — " @@ -65,9 +66,9 @@ def load_central_config(root): layers = [ base, - os.path.join(bmad_dir, "config.user.toml"), - os.path.join(bmad_dir, "custom", "config.toml"), - os.path.join(bmad_dir, "custom", "config.user.toml"), + posixpath.join(bmad_dir, "config.user.toml"), + posixpath.join(bmad_dir, "custom", "config.toml"), + posixpath.join(bmad_dir, "custom", "config.user.toml"), ] merged = {} for path in layers: @@ -110,7 +111,8 @@ def main(): script_dir = os.path.dirname(os.path.abspath(__file__)) skill_name = os.path.basename(script_dir) root = find_project_root() - bmad_dir = os.path.join(root, "_bmad") + root = root.replace(os.sep, "/") + bmad_dir = posixpath.join(root, "_bmad") vars_ = flatten_central_config(load_central_config(root)) @@ -118,23 +120,23 @@ def main(): vars_[key] = vars_[key].replace("{project-root}", root) vars_["project_root"] = root - vars_["main_config"] = os.path.join(bmad_dir, "config.toml") - vars_["sprint_status"] = os.path.join( + vars_["main_config"] = posixpath.join(bmad_dir, "config.toml") + vars_["sprint_status"] = posixpath.join( vars_["implementation_artifacts"], "sprint-status.yaml" ) - vars_["deferred_work_file"] = os.path.join( + vars_["deferred_work_file"] = posixpath.join( vars_["implementation_artifacts"], "deferred-work.md" ) - out_dir = os.path.join(root, "_bmad", "render", skill_name) + out_dir = posixpath.join(root, "_bmad", "render", skill_name) os.makedirs(out_dir, exist_ok=True) count = 0 for fname in sorted(os.listdir(script_dir)): if not fname.endswith(".md") or fname == "SKILL.md": continue - src = os.path.join(script_dir, fname) - dst = os.path.join(out_dir, fname) + src = posixpath.join(script_dir, fname) + dst = posixpath.join(out_dir, fname) with open(src, "r", encoding="utf-8") as fh: content = fh.read() with open(dst, "w", encoding="utf-8") as fh: @@ -142,7 +144,7 @@ def main(): count += 1 print(f"render.py: rendered {count} files -> {out_dir}", file=sys.stderr) - workflow_md = os.path.join(out_dir, "workflow.md") + workflow_md = posixpath.join(out_dir, "workflow.md") print(f"read and follow {workflow_md}") From c31a892f6ddc70039d3bfd428fb80810650dea44 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 21 Apr 2026 23:50:22 -0700 Subject: [PATCH 04/13] fix(quick-dev): preserve source line endings in render.py Python text-mode open() with the platform default performs universal- newline translation: on Windows, LF source files get written as CRLF, producing spurious diffs when rendered output is compared against source. Pass newline="" on both the source read and the rendered write so line endings pass through verbatim. Part of plan-quick-dev-python-config-hardening.md (F4). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bmm-skills/4-implementation/bmad-quick-dev/render.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py index 4853b5852..58993b916 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -137,9 +137,9 @@ def main(): continue src = posixpath.join(script_dir, fname) dst = posixpath.join(out_dir, fname) - with open(src, "r", encoding="utf-8") as fh: + with open(src, "r", encoding="utf-8", newline="") as fh: content = fh.read() - with open(dst, "w", encoding="utf-8") as fh: + with open(dst, "w", encoding="utf-8", newline="") as fh: fh.write(render_template(content, vars_)) count += 1 From ad428e0f9fa950e42e47ff011e3e505abd9b09ca Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 21 Apr 2026 23:53:52 -0700 Subject: [PATCH 05/13] fix(quick-dev): delete stale .md renders before rebuilding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit render.py rebuilds from scratch per the docstring, but makedirs(exist_ok=True) only overwrites files that still exist in the source — stale outputs from renamed/deleted source files linger in _bmad/render/bmad-quick-dev/ forever. Remove every .md in the render dir before the render loop; keep the dir itself and any non-.md files. Part of plan-quick-dev-python-config-hardening.md (F5). Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bmm-skills/4-implementation/bmad-quick-dev/render.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py index 58993b916..e29f23417 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -131,6 +131,10 @@ def main(): out_dir = posixpath.join(root, "_bmad", "render", skill_name) os.makedirs(out_dir, exist_ok=True) + for fname in os.listdir(out_dir): + if fname.endswith(".md"): + os.remove(posixpath.join(out_dir, fname)) + count = 0 for fname in sorted(os.listdir(script_dir)): if not fname.endswith(".md") or fname == "SKILL.md": From e897fa6207a4fd4a61a4fb52e16ae04e34292836 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 21 Apr 2026 23:55:32 -0700 Subject: [PATCH 06/13] fix(quick-dev): scope render/ whitelist to bmad-quick-dev The previous INSTALL_ONLY_PATHS entry 'render/' was a blanket prefix that let every {project-root}/_bmad/render/... reference in any skill slip past validation. Narrow to 'render/bmad-quick-dev/' so only this skill's render buffer is whitelisted. Future skills adopting the stdout-dispatch renderer pattern add their own entries explicitly. Part of plan-quick-dev-python-config-hardening.md (F6). Co-Authored-By: Claude Opus 4.7 (1M context) --- tools/validate-file-refs.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/validate-file-refs.js b/tools/validate-file-refs.js index 14835de81..31d5db83c 100644 --- a/tools/validate-file-refs.js +++ b/tools/validate-file-refs.js @@ -80,7 +80,7 @@ function escapeTableCell(str) { } // Path prefixes/patterns that only exist in installed structure, not in source -const INSTALL_ONLY_PATHS = ['_config/', 'custom/', 'render/']; +const INSTALL_ONLY_PATHS = ['_config/', 'custom/', 'render/bmad-quick-dev/']; // Files that are generated at install time and don't exist in the source tree const INSTALL_GENERATED_FILES = ['config.yaml', 'config.user.yaml']; From 839be1193221c086a18709e60fc6a55584792b6e Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Tue, 21 Apr 2026 23:58:52 -0700 Subject: [PATCH 07/13] test(quick-dev): add renderer smoke test with TOML override New test/test-quick-dev-renderer.js spins up a temp project with base _bmad/config.toml and a _bmad/custom/config.user.toml override, runs render.py, and asserts the override wins in rendered workflow.md and that sprint_status is rooted at an absolute path in the temp project. Registered as test:renderer in package.json and chained into the npm test script. Part of plan-quick-dev-python-config-hardening.md (F7). Co-Authored-By: Claude Opus 4.7 (1M context) --- package.json | 5 +- test/test-quick-dev-renderer.js | 175 ++++++++++++++++++++++++++++++++ 2 files changed, 178 insertions(+), 2 deletions(-) create mode 100644 test/test-quick-dev-renderer.js diff --git a/package.json b/package.json index 505c6e8e0..1ceced8d2 100644 --- a/package.json +++ b/package.json @@ -40,12 +40,13 @@ "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix", "lint:md": "markdownlint-cli2 \"**/*.md\"", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", - "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar", + "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run test:renderer && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar", "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", - "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check", + "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run test:renderer && npm run lint && npm run lint:md && npm run format:check", "test:channels": "node test/test-installer-channels.js", "test:install": "node test/test-installation-components.js", "test:refs": "node test/test-file-refs-csv.js", + "test:renderer": "node test/test-quick-dev-renderer.js", "test:urls": "node test/test-parse-source-urls.js", "validate:refs": "node tools/validate-file-refs.js --strict", "validate:skills": "node tools/validate-skills.js --strict" diff --git a/test/test-quick-dev-renderer.js b/test/test-quick-dev-renderer.js new file mode 100644 index 000000000..d65263f2a --- /dev/null +++ b/test/test-quick-dev-renderer.js @@ -0,0 +1,175 @@ +/** + * Smoke test for bmad-quick-dev render.py + * + * Sets up a temp project with a base _bmad/config.toml and an override + * _bmad/custom/config.user.toml, runs render.py, and asserts: + * 1. The override wins (workflow.md contains "Japanese"). + * 2. sprint_status is an absolute path rooted at the temp project dir. + * + * Usage: node test/test-quick-dev-renderer.js + * Exit codes: 0 = all tests pass, 1 = test failures + */ + +'use strict'; + +const fs = require('node:fs'); +const os = require('node:os'); +const path = require('node:path'); +const { spawnSync } = require('node:child_process'); + +// ANSI color codes (same as other test files) +const colors = { + reset: '\u001B[0m', + green: '\u001B[32m', + red: '\u001B[31m', + cyan: '\u001B[36m', +}; + +let totalTests = 0; +let passedTests = 0; +const failures = []; + +function test(name, fn) { + totalTests++; + try { + fn(); + passedTests++; + console.log(` ${colors.green}\u2713${colors.reset} ${name}`); + } catch (error) { + console.log(` ${colors.red}\u2717${colors.reset} ${name} ${colors.red}${error.message}${colors.reset}`); + failures.push({ name, message: error.message }); + } +} + +function assert(condition, message) { + if (!condition) throw new Error(message); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +const SKILL_SRC = path.join(__dirname, '..', 'src', 'bmm-skills', '4-implementation', 'bmad-quick-dev'); + +/** + * Recursively copy a directory (stdlib only, no fs.cp to stay >=20 compat). + */ +function copyDirSync(src, dst) { + fs.mkdirSync(dst, { recursive: true }); + for (const entry of fs.readdirSync(src, { withFileTypes: true })) { + const srcPath = path.join(src, entry.name); + const dstPath = path.join(dst, entry.name); + if (entry.isDirectory()) { + copyDirSync(srcPath, dstPath); + } else { + fs.copyFileSync(srcPath, dstPath); + } + } +} + +// --------------------------------------------------------------------------- +// Test fixture setup +// --------------------------------------------------------------------------- + +const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-renderer-test-')); + +try { + // _bmad/config.toml — base layer + fs.mkdirSync(path.join(tmpDir, '_bmad'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '_bmad', 'config.toml'), + [ + '[core]', + 'communication_language = "French"', + '', + '[modules.bmm]', + 'planning_artifacts = "{project-root}/plan"', + 'implementation_artifacts = "{project-root}/impl"', + ].join('\n'), + 'utf-8', + ); + + // _bmad/custom/config.user.toml — override layer (should win) + fs.mkdirSync(path.join(tmpDir, '_bmad', 'custom'), { recursive: true }); + fs.writeFileSync( + path.join(tmpDir, '_bmad', 'custom', 'config.user.toml'), + ['[core]', 'communication_language = "Japanese"'].join('\n'), + 'utf-8', + ); + + // Copy skill dir into /bmad-quick-dev/ so find_project_root() walks + // up and finds /_bmad/, and os.path.basename(script_dir) resolves + // to the real skill name so the render output lands at + // _bmad/render/bmad-quick-dev/workflow.md. + const skillDst = path.join(tmpDir, 'bmad-quick-dev'); + copyDirSync(SKILL_SRC, skillDst); + + // --------------------------------------------------------------------------- + // Run render.py + // --------------------------------------------------------------------------- + + console.log(`\n${colors.cyan}Quick-dev renderer smoke tests${colors.reset}\n`); + + const result = spawnSync('python3', [path.join(skillDst, 'render.py')], { + cwd: skillDst, + encoding: 'utf-8', + }); + + // --------------------------------------------------------------------------- + // Tests + // --------------------------------------------------------------------------- + + test('render.py exits with code 0', () => { + assert(result.status === 0, `exit code ${result.status}\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + }); + + test('workflow.md exists in render output', () => { + const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md'); + assert(fs.existsSync(rendered), `workflow.md not found at ${rendered}`); + }); + + test('custom override wins — workflow.md contains "Japanese"', () => { + const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md'); + const content = fs.readFileSync(rendered, 'utf-8'); + assert(content.includes('Japanese'), `"Japanese" not found in workflow.md (communication_language override did not win)`); + }); + + test('sprint_status is an absolute path rooted at temp project dir', () => { + const rendered = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev', 'workflow.md'); + const content = fs.readFileSync(rendered, 'utf-8'); + // Normalize to forward slashes for cross-platform matching + const normalizedTmp = tmpDir.replaceAll('\\', '/'); + // sprint_status should appear as /impl/sprint-status.yaml + const expected = `${normalizedTmp}/impl/sprint-status.yaml`; + assert( + content.includes(expected), + `sprint_status path not found.\nExpected substring: ${expected}\n` + + `workflow.md excerpt (first 2000 chars):\n${content.slice(0, 2000)}`, + ); + }); +} finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); +} + +// --------------------------------------------------------------------------- +// Summary +// --------------------------------------------------------------------------- + +console.log(`\n${colors.cyan}${'═'.repeat(55)}${colors.reset}`); +console.log(`${colors.cyan}Test Results:${colors.reset}`); +console.log(` Total: ${totalTests}`); +console.log(` Passed: ${colors.green}${passedTests}${colors.reset}`); +console.log(` Failed: ${passedTests === totalTests ? colors.green : colors.red}${totalTests - passedTests}${colors.reset}`); +console.log(`${colors.cyan}${'═'.repeat(55)}${colors.reset}\n`); + +if (failures.length > 0) { + console.log(`${colors.red}FAILED TESTS:${colors.reset}\n`); + for (const failure of failures) { + console.log(`${colors.red}\u2717${colors.reset} ${failure.name}`); + console.log(` ${failure.message}\n`); + } + process.exit(1); +} + +console.log(`${colors.green}All tests passed!${colors.reset}\n`); +process.exit(0); From b290a152983e364ff5caa733ec272ee025b716ce Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sun, 24 May 2026 20:28:51 -0700 Subject: [PATCH 08/13] fix(quick-dev): HALT cleanly when base config.toml is unparseable Load the four config layers through a load_toml helper that marks the base _bmad/config.toml as required. A missing, unparseable, or unreadable base now prints a HALT directive to stdout and exits, instead of being silently skipped and then crashing downstream with a KeyError when a derived value (e.g. implementation_artifacts) is absent. Optional layers still warn on stderr and fall back to empty. Merge semantics are unchanged (dict-aware deep merge, override wins for lists and scalars). --- .../4-implementation/bmad-quick-dev/render.py | 72 +++++++++++-------- 1 file changed, 44 insertions(+), 28 deletions(-) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py index e29f23417..fbb034262 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -7,7 +7,8 @@ rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/. Config: four-layer merge of _bmad/config.toml + config.user.toml + custom/config.toml + custom/config.user.toml (post-#2285 installs). -Keys surface from [core] and [modules.bmm]. Missing config.toml → HALT. +Keys surface from [core] and [modules.bmm]. Missing or unparseable +config.toml → HALT. Runtime {variable} placeholders (single curly) pass through untouched for the LLM to resolve during workflow execution. @@ -40,6 +41,39 @@ def find_project_root(): current = parent +def load_toml(path, required=False): + """Load a TOML file. For required files, HALT (stdout) on missing/parse + error so the LLM-driven workflow stops — stdout is how this script signals + workflow halts to its LLM caller. For optional files, write a stderr + warning and return {}.""" + if not os.path.isfile(path): + if required: + print( + f"HALT and report to the user: required config file not found: {path} — " + "ensure this is a post-#2285 BMAD install" + ) + sys.exit(1) + return {} + try: + with open(path, "rb") as fh: + parsed = tomllib.load(fh) + except tomllib.TOMLDecodeError as error: + if required: + print(f"HALT and report to the user: failed to parse {path}: {error}") + sys.exit(1) + print(f"render.py: warning: failed to parse {path}: {error}", file=sys.stderr) + return {} + except OSError as error: + if required: + print(f"HALT and report to the user: failed to read {path}: {error}") + sys.exit(1) + print(f"render.py: warning: failed to read {path}: {error}", file=sys.stderr) + return {} + if not isinstance(parsed, dict): + return {} + return parsed + + def _deep_merge(base, override): """Dict-aware deep merge. Lists and scalars: override wins (we don't need the full keyed-merge semantics of resolve_config.py — quick-dev only reads @@ -53,35 +87,17 @@ def _deep_merge(base, override): def load_central_config(root): - """Four-layer merge of _bmad/config.toml and its peers. HALTs if the base - _bmad/config.toml is absent.""" + """Four-layer merge of _bmad/config.toml and its peers (highest priority + last). HALTs if the base _bmad/config.toml is missing or unparseable.""" bmad_dir = posixpath.join(root, "_bmad") - base = posixpath.join(bmad_dir, "config.toml") - if not os.path.isfile(base): - print( - f"HALT and report to the user: central config not found at {base} — " - "ensure this is a post-#2285 BMAD install" - ) - sys.exit(1) + base_team = load_toml(posixpath.join(bmad_dir, "config.toml"), required=True) + base_user = load_toml(posixpath.join(bmad_dir, "config.user.toml")) + custom_team = load_toml(posixpath.join(bmad_dir, "custom", "config.toml")) + custom_user = load_toml(posixpath.join(bmad_dir, "custom", "config.user.toml")) - layers = [ - base, - posixpath.join(bmad_dir, "config.user.toml"), - posixpath.join(bmad_dir, "custom", "config.toml"), - posixpath.join(bmad_dir, "custom", "config.user.toml"), - ] - merged = {} - for path in layers: - if not os.path.isfile(path): - continue - try: - with open(path, "rb") as fh: - data = tomllib.load(fh) - except (tomllib.TOMLDecodeError, OSError) as error: - print(f"render.py: skipping {path}: {error}", file=sys.stderr) - continue - if isinstance(data, dict): - merged = _deep_merge(merged, data) + merged = _deep_merge(base_team, base_user) + merged = _deep_merge(merged, custom_team) + merged = _deep_merge(merged, custom_user) return merged From 61531ffaee05ddaf03683c509f5f2409bbfeeae9 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sun, 24 May 2026 22:52:25 -0700 Subject: [PATCH 09/13] fix(quick-dev): resolve render.py via {skill-root} in skill entry shim MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The bare `python render.py` shim assumes the agent's working directory is the skill directory, but agents run from the project root, so the script is not found. Reference it as `{skill-root}/render.py` — BMAD's standard token for a skill's installed directory, already used by every other skill's resolve_customization.py invocation — and add the one-line `{skill-root}` explainer so the model resolves it from an instruction rather than guessing. Interpreter stays `python`; the python vs python3 choice is a separate cross-platform concern. --- src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 9bcc3f176..7047a70cb 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md @@ -3,8 +3,10 @@ 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.' --- +`{skill-root}` is this skill's installed directory. + ``` -python render.py +python {skill-root}/render.py ``` Then follow the instruction it prints to stdout. From 7e65f5004cf4d59c7cfb5e85ce87ff9f1e6e6852 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sun, 24 May 2026 23:12:31 -0700 Subject: [PATCH 10/13] refactor(quick-dev): resolve [workflow] customization in render.py render.py now merges the three customize layers (customize.toml -> custom/bmad-quick-dev.toml -> .user.toml) with the same structural rules as resolve_customization.py and inlines the resolved [workflow] values, so no {workflow.*} placeholder survives. workflow.md drops its Step 1 runtime resolver + manual-merge fallback; step-05 and step-oneshot drop their runtime workflow.on_complete calls. The shared resolve_customization.py and every other skill are untouched. Smoke test extended with a [workflow] override fixture covering inlining, array append, and no-leak assertions. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../4-implementation/bmad-quick-dev/render.py | 129 +++++++++++++++++- .../bmad-quick-dev/step-05-present.md | 4 +- .../bmad-quick-dev/step-oneshot.md | 4 +- .../bmad-quick-dev/workflow.md | 30 ++-- test/test-quick-dev-renderer.js | 63 ++++++++- 5 files changed, 200 insertions(+), 30 deletions(-) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py index fbb034262..157f97788 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -2,16 +2,21 @@ """render.py — bmad-quick-dev template renderer. Resolves compile-time {{.variable}} placeholders from BMad's central config, -bakes absolute paths for {project-root} into derived values, and writes -rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/. +bakes absolute paths for {project-root} into derived values, resolves and +inlines the skill's [workflow] customization block, and writes rendered .md +files to {project-root}/_bmad/render/bmad-quick-dev/. Config: four-layer merge of _bmad/config.toml + config.user.toml + custom/config.toml + custom/config.user.toml (post-#2285 installs). Keys surface from [core] and [modules.bmm]. Missing or unparseable config.toml → HALT. -Runtime {variable} placeholders (single curly) pass through untouched for -the LLM to resolve during workflow execution. +Customization: three-layer merge of {skill}/customize.toml + +_bmad/custom/bmad-quick-dev.toml + .user.toml (same structural rules as +resolve_customization.py). The resolved [workflow] values fill {workflow.*} +placeholders, so this skill needs no runtime resolve_customization.py call. +Other single-curly placeholders ({project-root}, {spec_file}, {skill-root}, +...) pass through untouched for the LLM to resolve during workflow execution. Every invocation rebuilds from scratch — no hash, no cache. Python 3.11+ stdlib only. UTF-8 I/O. @@ -86,6 +91,83 @@ def _deep_merge(base, override): return override +def _detect_keyed_merge_field(items): + """Return 'code' or 'id' if every table item carries that same field. + Mixed or partial arrays return None and fall through to append.""" + if not items or not all(isinstance(item, dict) for item in items): + return None + for candidate in ("code", "id"): + if all(item.get(candidate) is not None for item in items): + return candidate + return None + + +def _merge_by_key(base, override, key_name): + result = [] + index_by_key = {} + for item in base: + if not isinstance(item, dict): + continue + if item.get(key_name) is not None: + index_by_key[item[key_name]] = len(result) + result.append(dict(item)) + for item in override: + if not isinstance(item, dict): + result.append(item) + continue + key = item.get(key_name) + if key is not None and key in index_by_key: + result[index_by_key[key]] = dict(item) + else: + if key is not None: + index_by_key[key] = len(result) + result.append(dict(item)) + return result + + +def _merge_arrays(base, override): + """Shape-aware array merge: keyed merge if every item has code/id, else append.""" + base_arr = base if isinstance(base, list) else [] + override_arr = override if isinstance(override, list) else [] + keyed_field = _detect_keyed_merge_field(base_arr + override_arr) + if keyed_field: + return _merge_by_key(base_arr, override_arr, keyed_field) + return base_arr + override_arr + + +def _structural_merge(base, override): + """Faithful port of resolve_customization.py's deep_merge: tables deep-merge, + arrays-of-tables keyed by code/id replace-then-append (other arrays append), + scalars override. Used only for the [workflow] customization layers — the + central-config path keeps its own simpler _deep_merge. Duplicated rather than + imported to keep this skill self-contained.""" + if isinstance(base, dict) and isinstance(override, dict): + result = dict(base) + for key, over_val in override.items(): + result[key] = ( + _structural_merge(result[key], over_val) if key in result else over_val + ) + return result + if isinstance(base, list) and isinstance(override, list): + return _merge_arrays(base, override) + return override + + +def resolve_workflow(root, skill_dir, skill_name): + """Resolve the [workflow] customization block via the three-layer merge + (skill defaults -> team -> user), highest priority last. Same structural + rules as resolve_customization.py. All three layers are optional: a missing + or unparseable file warns (via load_toml) and is skipped.""" + defaults = load_toml(posixpath.join(skill_dir, "customize.toml")) + custom_dir = posixpath.join(root, "_bmad", "custom") + team = load_toml(posixpath.join(custom_dir, f"{skill_name}.toml")) + user = load_toml(posixpath.join(custom_dir, f"{skill_name}.user.toml")) + merged = _structural_merge(defaults, team) + merged = _structural_merge(merged, user) + workflow = merged.get("workflow") + return workflow if isinstance(workflow, dict) else {} + + def load_central_config(root): """Four-layer merge of _bmad/config.toml and its peers (highest priority last). HALTs if the base _bmad/config.toml is missing or unparseable.""" @@ -123,6 +205,41 @@ def render_template(content, vars_): return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content) +def _scalar_str(value): + """Stringify a scalar for inline rendering: booleans lowercase (matching + BMad config conventions), None as empty, everything else via str().""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _render_workflow_value(value): + """Format a resolved [workflow] value for inline substitution. Lists render + as markdown bullets (empty -> '_None._'); scalars render verbatim. Each list + item uses the same scalar formatting so booleans stay consistent. Entries are + emitted as-is so runtime placeholders like {project-root} survive for the LLM + to resolve.""" + if isinstance(value, list): + if not value: + return "_None._" + return "\n".join(f"- {_scalar_str(item)}" for item in value) + return _scalar_str(value) + + +def render_workflow(content, workflow): + """Resolve {workflow.} placeholders from the resolved [workflow] block. + Unknown keys emit an empty string (missingkey=zero, matching render_template). + Distinct regex from render_template so single-curly runtime placeholders + elsewhere are untouched.""" + return re.sub( + r"\{workflow\.(\w+)\}", + lambda m: _render_workflow_value(workflow.get(m.group(1))), + content, + ) + + def main(): script_dir = os.path.dirname(os.path.abspath(__file__)) skill_name = os.path.basename(script_dir) @@ -144,6 +261,8 @@ def main(): vars_["implementation_artifacts"], "deferred-work.md" ) + workflow = resolve_workflow(root, script_dir.replace(os.sep, "/"), skill_name) + out_dir = posixpath.join(root, "_bmad", "render", skill_name) os.makedirs(out_dir, exist_ok=True) @@ -160,7 +279,7 @@ def main(): with open(src, "r", encoding="utf-8", newline="") as fh: content = fh.read() with open(dst, "w", encoding="utf-8", newline="") as fh: - fh.write(render_template(content, vars_)) + fh.write(render_workflow(render_template(content, vars_), workflow)) count += 1 print(f"render.py: rendered {count} files -> {out_dir}", file=sys.stderr) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md index a3ff75c2e..620b043d7 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md @@ -73,6 +73,6 @@ Workflow complete. ## On Complete -Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete` +If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally. -If the resolved `workflow.on_complete` is non-empty, follow it as the final terminal instruction before exiting. +{workflow.on_complete} diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md index f6cb711a7..291e20bde 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md @@ -62,6 +62,6 @@ Workflow complete. ## On Complete -Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete` +If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally. -If the resolved `workflow.on_complete` is non-empty, follow it as the final terminal instruction before exiting. +{workflow.on_complete} diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md index f9e8f806a..632fb5047 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md @@ -35,27 +35,19 @@ A specification should target a **single user-facing goal** within **900–1600 ## On Activation -### Step 1: Resolve the Workflow Block +### Step 1: Execute Prepend Steps -Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow` +Execute each of these steps in order before proceeding (`_None._` means skip): -**If the script fails**, resolve the `workflow` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver: +{workflow.activation_steps_prepend} -1. `{skill-root}/customize.toml` — defaults -2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides -3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides +### Step 2: Load Persistent Facts -Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append. +Treat every entry below as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim (`_None._` means none): -### Step 2: Execute Prepend Steps +{workflow.persistent_facts} -Execute each entry in `{workflow.activation_steps_prepend}` in order before proceeding. - -### Step 3: Load Persistent Facts - -Treat every entry in `{workflow.persistent_facts}` as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim. - -### Step 4: Load Config +### Step 3: Load Config Load config from `{{.main_config}}` and resolve: @@ -69,13 +61,15 @@ Load config from `{{.main_config}}` and resolve: - Language MUST be tailored to `{{.user_skill_level}}` - Generate all documents in `{{.document_output_language}}` -### Step 5: Greet the User +### Step 4: Greet the User Greet `{{.user_name}}`, speaking in `{{.communication_language}}`. -### Step 6: Execute Append Steps +### Step 5: Execute Append Steps -Execute each entry in `{workflow.activation_steps_append}` in order. +Execute each of these steps in order (`_None._` means skip): + +{workflow.activation_steps_append} Activation is complete. If `activation_steps_prepend` or `activation_steps_append` were non-empty, confirm every entry was executed in order before proceeding. Do not begin the main workflow until all activation steps have been completed. diff --git a/test/test-quick-dev-renderer.js b/test/test-quick-dev-renderer.js index d65263f2a..22746fc70 100644 --- a/test/test-quick-dev-renderer.js +++ b/test/test-quick-dev-renderer.js @@ -1,10 +1,16 @@ /** * Smoke test for bmad-quick-dev render.py * - * Sets up a temp project with a base _bmad/config.toml and an override - * _bmad/custom/config.user.toml, runs render.py, and asserts: - * 1. The override wins (workflow.md contains "Japanese"). + * Sets up a temp project with base + override config layers and a + * _bmad/custom/bmad-quick-dev.user.toml [workflow] override, runs render.py, + * and asserts: + * 1. The central-config override wins (workflow.md contains "Japanese"). * 2. sprint_status is an absolute path rooted at the temp project dir. + * 3. [workflow] customization is self-resolved and inlined: prepend bullet, + * persistent_facts append (base kept), empty list -> _None._, on_complete + * scalar baked into step-05/step-oneshot. + * 4. No {workflow.*} placeholder or resolve_customization.py call survives + * in any rendered file. * * Usage: node test/test-quick-dev-renderer.js * Exit codes: 0 = all tests pass, 1 = test failures @@ -97,6 +103,21 @@ try { 'utf-8', ); + // _bmad/custom/bmad-quick-dev.user.toml — [workflow] customization override. + // Exercises render.py's self-resolution: array append (persistent_facts), + // list inlining (activation_steps_prepend), and scalar override (on_complete), + // all baked into the rendered output with no runtime resolve_customization.py. + fs.writeFileSync( + path.join(tmpDir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'), + [ + '[workflow]', + 'activation_steps_prepend = ["TEST_PREPEND_STEP"]', + 'persistent_facts = ["TEST_EXTRA_FACT"]', + 'on_complete = "TEST_ON_COMPLETE_INSTRUCTION"', + ].join('\n'), + 'utf-8', + ); + // Copy skill dir into /bmad-quick-dev/ so find_project_root() walks // up and finds /_bmad/, and os.path.basename(script_dir) resolves // to the real skill name so the render output lands at @@ -115,6 +136,10 @@ try { encoding: 'utf-8', }); + const renderDir = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev'); + const readRendered = (name) => fs.readFileSync(path.join(renderDir, name), 'utf-8'); + const renderedMdFiles = () => fs.readdirSync(renderDir).filter((f) => f.endsWith('.md')); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -147,6 +172,38 @@ try { `workflow.md excerpt (first 2000 chars):\n${content.slice(0, 2000)}`, ); }); + + test('workflow override — prepend step inlined as a bullet', () => { + const content = readRendered('workflow.md'); + assert(content.includes('- TEST_PREPEND_STEP'), 'activation_steps_prepend not inlined as a bullet'); + }); + + test('workflow override — persistent_facts append (base kept, override added)', () => { + const content = readRendered('workflow.md'); + assert(content.includes('- TEST_EXTRA_FACT'), 'override persistent_fact not inlined'); + assert(content.includes('project-context.md'), 'base persistent_fact dropped — append semantics broken'); + }); + + test('empty activation_steps_append renders the _None._ sentinel', () => { + const content = readRendered('workflow.md'); + assert(content.includes('_None._'), '_None._ sentinel missing for empty list'); + }); + + test('on_complete scalar inlined into step-05 and step-oneshot', () => { + for (const file of ['step-05-present.md', 'step-oneshot.md']) { + assert(readRendered(file).includes('TEST_ON_COMPLETE_INSTRUCTION'), `on_complete not inlined into ${file}`); + } + }); + + test('no {workflow.*} placeholder survives in any rendered file', () => { + const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('{workflow.')); + assert(leaks.length === 0, `{workflow.*} leaked in: ${leaks.join(', ')}`); + }); + + test('no resolve_customization.py reference survives in any rendered file', () => { + const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('resolve_customization.py')); + assert(leaks.length === 0, `resolve_customization.py still referenced in: ${leaks.join(', ')}`); + }); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); } From 0c3b9291a084ace1bf81abcac17bf0794c5f9847 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Sun, 24 May 2026 23:25:59 -0700 Subject: [PATCH 11/13] fix(quick-dev): harden render.py invocation in the SKILL.md shim The shim called bare `python`, which can resolve to Python 2 or be absent; render.py needs 3.11+ for tomllib. Spell out python3 and the version requirement. Also make the exit code authoritative: on a non-zero exit (including an uncaught crash that writes only to stderr), do not proceed -- report what was printed and stop. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../4-implementation/bmad-quick-dev/SKILL.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) 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 7047a70cb..1de96b16b 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/SKILL.md @@ -3,10 +3,12 @@ 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.' --- -`{skill-root}` is this skill's installed directory. +Run this, substituting `{skill-root}` with the absolute path to this skill's base directory, without changing the cwd: -``` -python {skill-root}/render.py +```bash +python3 {skill-root}/render.py ``` -Then follow the instruction it prints to stdout. +- **On success:** follow the instruction it prints to stdout; ignore stderr. +- **If `python3` is missing or lacks `tomllib`:** recover and retry. +- **Any other failure:** report what it printed and HALT. From 46c5173b9c511370eddbbf59a94c936c182cd72c Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Mon, 25 May 2026 00:38:13 -0700 Subject: [PATCH 12/13] chore(quick-dev): drop the render.py success stderr line The "rendered N files" progress line was pure diagnostic noise. The shim already tells the LLM to ignore stderr and follow the stdout instruction, so on success render.py now prints only the "read and follow ..." line. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/bmm-skills/4-implementation/bmad-quick-dev/render.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py index 157f97788..b5faed752 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -270,7 +270,6 @@ def main(): if fname.endswith(".md"): os.remove(posixpath.join(out_dir, fname)) - count = 0 for fname in sorted(os.listdir(script_dir)): if not fname.endswith(".md") or fname == "SKILL.md": continue @@ -280,9 +279,7 @@ def main(): content = fh.read() with open(dst, "w", encoding="utf-8", newline="") as fh: fh.write(render_workflow(render_template(content, vars_), workflow)) - count += 1 - print(f"render.py: rendered {count} files -> {out_dir}", file=sys.stderr) workflow_md = posixpath.join(out_dir, "workflow.md") print(f"read and follow {workflow_md}") From 93ff8d458fdd99de339d53bcd4d612916ac91462 Mon Sep 17 00:00:00 2001 From: Alex Verkhovsky Date: Thu, 11 Jun 2026 03:01:16 -0700 Subject: [PATCH 13/13] fix(quick-dev): drop the activation gate sentence from the rendered workflow The gate ported from #2398 defended against runtime customization indirection: agents guessed resolver outputs instead of executing them, silently skipping append steps. render.py inlines the prepend/append entries into the rendered workflow.md, so there is nothing left to short-circuit, and each inlined list already carries its own execute- in-order imperative. In the default install both lists render as _None._ and the gate is pure noise. --- src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md index 632fb5047..a910196fb 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md @@ -71,8 +71,6 @@ Execute each of these steps in order (`_None._` means skip): {workflow.activation_steps_append} -Activation is complete. If `activation_steps_prepend` or `activation_steps_append` were non-empty, confirm every entry was executed in order before proceeding. Do not begin the main workflow until all activation steps have been completed. - ## WORKFLOW ARCHITECTURE This uses **step-file architecture** for disciplined execution: