feat(dev-auto): restore skill-entry renderer with fail-early hardening

Reinstates the bmad-dev-auto template renderer temporarily reverted in
#2598, hardened. SKILL.md becomes a thin dispatch stub (uv run
render.py) and the workflow/step files are rendered at skill entry with
central-config values, the resolved [workflow] customization block, and
absolute sibling cross-references baked in — no runtime config loading
or resolve_customization.py call remains.

Fail-early contract: anything the renderer can detect wrong HALTs on
stdout with an error informative enough for the presiding inference to
act on — missing, empty, or non-string config keys referenced by the
sources; unknown or malformed [workflow] values (including non-scalar
layer fields); unreadable or non-UTF-8 sources; and failed publish
swaps. A publish swap lost to a concurrent renderer that already
published byte-identical content remains a success (rendering is
deterministic), and the last-good render is preserved for the next run
on any other swap failure.

Both render.py copies (dev-auto, quick-dev) change in lockstep; a drift
test pins them byte-for-byte modulo skill names. New
test/test-dev-auto-renderer.js covers rendering, overrides, review-layer
materialization, every HALT path, and injected-rename publish failures;
the quick-dev suite gains the no-op re-render and dispatch-line pins.
This commit is contained in:
Alex Verkhovsky
2026-07-17 08:51:51 -07:00
parent 717479bc3f
commit 665643bbc4
16 changed files with 1835 additions and 185 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ Exactly one `stories.yaml` entry is dispatched per invocation: the workflow neve
On activation, the workflow resolves:
- `_bmad/bmm/config.yaml`
- BMad configuration from `_bmad/config.toml` and its override layers
- Any configured workflow customizations from `customize.toml`, team overrides, and user overrides
- Persistent facts listed in workflow config
- `project-context.md` files, if present
+1 -1
View File
@@ -74,7 +74,7 @@ workflow 读取 `<spec-folder>/stories.yaml`,查找 `id` 匹配的条目。它
激活时,workflow 解析:
- `_bmad/bmm/config.yaml`
- `_bmad/config.toml` 及其 override 层中的 BMad 配置
- `customize.toml`、团队 override、用户 override 中的 workflow 自定义
- workflow 配置中列出的 persistent facts
- 若存在的 `project-context.md` 文件
+1 -1
View File
@@ -46,7 +46,7 @@
"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:renderer": "node test/test-quick-dev-renderer.js && node test/test-dev-auto-renderer.js",
"test:skills": "node test/test-validate-skills.js",
"test:urls": "node test/test-parse-source-urls.js",
"validate:refs": "node tools/validate-file-refs.js --strict",
@@ -3,121 +3,11 @@ name: bmad-dev-auto
description: 'One iteration of an unattended development loop. Use when invoked by name.'
---
# Dev Auto Workflow
Run this, substituting `{skill-root}` with the absolute path to this skill's base directory, without changing the cwd:
**Goal:** Turn intent into a hardened, reviewable artifact, without human interaction.
```bash
uv run "{skill-root}/render.py"
```
**CRITICAL:** If a step says "read fully and follow step-XX", you read and follow step-XX. No exceptions.
## HALT
To HALT with a final status and optional blocking condition:
1. **Folder+id dispatch** (`{spec_folder}` and `{story_id}` are set): the write-back always lands at the id-keyed story spec. The `{implementation_artifacts}` fallback in step 2 below is never used in this mode, even for halts before planning starts.
- If `{spec_file}` is still empty, resolve it now:
- **Entry not resolved** (`stories.yaml` is missing/unparseable, or `{story_id}` has no matching entry): use the fixed slug segment `unresolved`: `{spec_file}` = `{spec_folder}/stories/{story_id}-unresolved.md`.
- **Ambiguous on-disk match** (the halt is `ambiguous story file match` — more than one file already matches `{spec_folder}/stories/{story_id}-*.md`): use the fixed slug segment `ambiguous` instead of deriving from the title, so the write-back neither creates a third title-derived candidate nor risks silently landing on one of the existing ambiguous files: `{spec_file}` = `{spec_folder}/stories/{story_id}-ambiguous.md`.
- **Otherwise** (the entry was resolved and no ambiguous on-disk match exists): derive `{spec_file}` = `{spec_folder}/stories/{story_id}-{slug}.md`, where `{slug}` is a kebab-case slug from `title` (and `description` if needed) with no `{story_id}` prefix — the same derivation step-01's Route uses.
- If `{spec_file}` exists on disk, update `status` in frontmatter and append missing result details under `## Auto Run Result`.
- If it does not exist, create it as a skeletal story spec:
```markdown
---
status: <final status>
---
# <entry title, or "Story {story_id}" if the entry could not be resolved or the on-disk match was ambiguous>
## Auto Run Result
Status: <final status>
Blocking condition: <blocking condition, if any>
```
2. **Otherwise:**
- If `{spec_file}` is known and exists, update `status` in frontmatter and append missing result details under `## Auto Run Result`.
- If `{spec_file}` is unknown or missing, create `{implementation_artifacts}/bmad-dev-auto-result-<slug-or-timestamp>.md` with:
```markdown
---
status: <final status>
---
# BMad Dev Auto Result
Status: <final status>
Blocking condition: <blocking condition, if any>
```
3. Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete`
4. If the resolved `workflow.on_complete` is non-empty, follow it as the final instruction before exiting.
5. Stop the workflow.
## Subagents
Using subagents when instructed is mandatory. If you cannot, HALT with status `blocked` and blocking condition `no subagents`.
Invoke every subagent **synchronously**: launch it, wait for it to return within the same turn, then continue with its result. When a step says to run subagents "in parallel" (e.g. the reviewers), that means several **blocking** calls awaited together in one turn — not detached execution. Never run a subagent in the background / detached / async (e.g. `run_in_background: true`), and never end your turn to "await a completion notification." This workflow runs unattended: there is no event loop to resume a yielded turn, so a backgrounded subagent never hands control back and the run stalls. The only sanctioned way to end a turn is the HALT protocol above with an explicit terminal `status`.
## 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.
- **Surface-anchored**: ACs observe the outermost surface the intent references — never a more internal proxy for it (e.g. the API response, not the database row behind it).
- **Complete**: No placeholders or TBDs.
- **Sufficient**: No known requirement, acceptance, dependency, or implementation gaps remain unresolved.
- **Coherent**: No unresolved ambiguities or internal contradictions.
## 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
- `project_context` = `**/project-context.md` (load if exists)
- 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: Execute Append Steps
Execute each entry in `{workflow.activation_steps_append}` in order.
Activation is complete after all activation steps have run.
## Workflow Execution
Follow the step files in order. Read one step fully, execute it, then load the next step only when directed. Do not skip, reorder, or pre-load steps.
## First workflow step
Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow.
- **On success:** follow the instruction it prints to stdout; ignore stderr.
- **On any failure** (including `uv` not being installed): report what it printed and HALT.
@@ -0,0 +1,660 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""render.py — bmad-dev-auto template renderer.
Resolves compile-time {{.variable}} placeholders from BMad's central config,
bakes absolute paths for {project-root} into derived values, resolves and
inlines the skill's [workflow] customization block, rewrites ./<sibling>.md
cross-references to their absolute publish paths, and writes rendered .md
files to {project-root}/_bmad/render/bmad-dev-auto/.
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. A {{.var}} referenced by this skill's .md sources but
absent from the merged config, present with an empty value, or present
with a non-string type → HALT (never a silent empty or garbage
substitution). Optional layers may be missing, but one that exists and
cannot be parsed or read → HALT.
Customization: three-layer merge of {skill}/customize.toml +
_bmad/custom/bmad-dev-auto.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 and republishes atomically: rendering
is deterministic (same _bmad config + same skill source -> same output), so
the result is staged in a sibling temp dir and swapped into place only if it
differs from what's already published — a no-op the vast majority of the
time. This keeps concurrent renderers (parallel agents, or worktrees sharing
one _bmad/ via symlink) from tearing each other's output: readers only ever
see a fully-old or fully-new publish, never a half-written one. See
_publish_atomically for the swap and its handling of the residual race.
Python 3.11+ stdlib only. UTF-8 I/O.
"""
import os
import posixpath
import re
import shutil
import sys
import tempfile
import tomllib
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 load_toml(path, required=False):
"""Load a TOML file. Only absence is negotiable: a missing optional file
returns {} (customization layers are optional), a missing required file
HALTs. A file that exists but cannot be parsed or read always HALTs —
stdout is how this script signals workflow halts to its LLM caller — the
user wrote it to be honored, and silently continuing with {} would discard
their customizations with no failure signal."""
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:
print(f"HALT and report to the user: failed to parse {path}: {error}")
sys.exit(1)
except OSError as error:
print(f"HALT and report to the user: failed to read {path}: {error}")
sys.exit(1)
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 — dev-auto 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 _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
file is skipped, but an unparseable one HALTs (via load_toml)."""
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."""
bmad_dir = posixpath.join(root, "_bmad")
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"))
merged = _deep_merge(base_team, base_user)
merged = _deep_merge(merged, custom_team)
merged = _deep_merge(merged, custom_user)
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). Also returns
the keys present with non-scalar values (arrays, tables, dates), so the
missing-vars HALT can name the actual problem instead of claiming a key
the user can see in their config is absent."""
flat = {}
unsupported = {}
modules = merged.get("modules")
modules = modules if isinstance(modules, dict) else {}
for section in (merged.get("core"), 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"
unsupported.pop(key, None)
elif isinstance(value, (str, int, float)):
flat[key] = str(value)
unsupported.pop(key, None)
else:
unsupported[key] = type(value).__name__
flat.pop(key, None)
return flat, unsupported
def render_template(content, vars_):
"""Resolve {{.var}} substitutions. Unresolved references emit an empty string,
but main() HALTs on any missing reference before rendering starts, so this
fallback never fires in practice."""
return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content)
def collect_missing_vars(sources, vars_):
"""Map each {{.var}} name referenced by the source .md files but absent from
the merged config to the files that reference it. A missing key must HALT:
missingkey=zero rendering would bake a corrupted workflow (empty paths,
blank language lines) with no failure signal."""
missing = {}
for fname, content in sources:
for name in re.findall(r"\{\{\.(\w+)\}\}", content):
if name not in vars_:
files = missing.setdefault(name, [])
if fname not in files:
files.append(fname)
return missing
def collect_empty_vars(sources, vars_):
"""Map each {{.var}} referenced by the source .md files whose merged-config
value is present but empty/whitespace to the files that reference it. Every
referenced key names something the workflow cannot function without (paths,
languages), so an empty value is always a config mistake — HALT rather than
bake blank prose into the rendered workflow."""
empty = {}
for fname, content in sources:
for name in re.findall(r"\{\{\.(\w+)\}\}", content):
if name in vars_ and not vars_[name].strip():
files = empty.setdefault(name, [])
if fname not in files:
files.append(fname)
return empty
def collect_missing_workflow_keys(sources, workflow):
"""Map each {workflow.<key>} name referenced by the source .md files but
absent from the resolved [workflow] block to the files that reference it.
A missing key must HALT: missingkey=zero rendering would bake an empty
handoff or review block into the workflow with no failure signal. Every
legitimate key ships in this skill's customize.toml defaults, so a miss
is always an authoring error (typo) or a broken install."""
missing = {}
for fname, content in sources:
for name in re.findall(r"\{workflow\.(\w+)\}", content):
if name not in workflow:
files = missing.setdefault(name, [])
if fname not in files:
files.append(fname)
return missing
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 _is_scalar(value):
"""True for values _scalar_str can render faithfully. Anything else (a
table, an array, a date) would bake its Python repr into the rendered
workflow — main() HALTs on those instead."""
return value is None or isinstance(value, (bool, str, int, float))
# [workflow] keys holding review layers ([[workflow.review_layers]] tables with
# id/name/instruction/when fields). This renderer knows this skill's
# customization schema outright — layer semantics are materialized here, not
# interpreted by the LLM at run time.
_REVIEW_LAYER_KEYS = ("review_layers", "oneshot_review_layers")
def _render_review_layers(layers):
"""Materialize review layers into direct invocation blocks. A layer with an
empty or missing instruction is disabled (that is how an override turns off
a default layer) and drops out entirely. A `when` condition is the one part
that stays with the LLM: it renders as a run-time guard line. No active
layers renders as the HALT instruction the workflow would otherwise have to
derive from an empty list."""
active = [
layer
for layer in layers
if isinstance(layer, dict) and _scalar_str(layer.get("instruction")).strip()
]
if not active:
return (
"No review layers are active. HALT with status `blocked` and "
"blocking condition `no active review layers`."
)
blocks = []
for layer in active:
title = (
_scalar_str(layer.get("name")).strip()
or _scalar_str(layer.get("id")).strip()
or "Review layer"
)
lines = [f"#### {title}", ""]
when = _scalar_str(layer.get("when")).strip()
if when:
lines.append(
"Run this layer only if the following holds in the "
f"current context: `{when}`"
)
lines.append("")
lines.append(_scalar_str(layer.get("instruction")).strip("\n"))
blocks.append("\n".join(lines))
return "\n\n".join(blocks)
def _render_workflow_value(key, value):
"""Format a resolved [workflow] value for inline substitution. Review-layer
keys materialize as invocation blocks; other 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} or {diff_output} survive
for the LLM to resolve."""
if key in _REVIEW_LAYER_KEYS and isinstance(value, list):
return _render_review_layers(value)
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.<key>} placeholders from the resolved [workflow] block.
Unknown keys emit an empty string, but main() HALTs on any missing key
before rendering starts (collect_missing_workflow_keys), so this fallback
never fires in practice — 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(m.group(1), workflow.get(m.group(1))),
content,
)
def _absolutize_sibling_refs(content, out_dir, filenames):
"""Rewrite ./<name>.md cross-references to their canonical absolute path
in the publish directory. Only rendered siblings are rewritten; any other
relative .md mention passes through untouched. The executor then never
resolves a relative path — every cross-file reference is absolute:
readable, or loudly missing. Never a silent hit on the unrendered
sources in the skill directory."""
rendered = set(filenames)
return re.sub(
r"\./([\w-]+\.md)",
lambda m: (
posixpath.join(out_dir, m.group(1))
if m.group(1) in rendered
else m.group(0)
),
content,
)
def _same_rendered_content(existing_dir, staging_dir, filenames):
"""True if existing_dir already holds exactly filenames with byte-identical
content to staging_dir. Missing/unreadable existing_dir, or any mismatch,
means "not the same" — always err toward publishing."""
if not os.path.isdir(existing_dir):
return False
try:
existing_names = {f for f in os.listdir(existing_dir) if f.endswith(".md")}
except OSError:
return False
if existing_names != set(filenames):
return False
for fname in filenames:
try:
with open(posixpath.join(existing_dir, fname), "rb") as a, open(
posixpath.join(staging_dir, fname), "rb"
) as b:
if a.read() != b.read():
return False
except OSError:
return False
return True
def _publish_atomically(out_dir, staging_dir, filenames):
"""Swap staging_dir into place at out_dir via same-directory renames,
which are atomic on a given filesystem — readers of out_dir see either the
fully-old or fully-new publish, never a half-written one. staging_dir
lives alongside out_dir (same parent), so both renames stay on the same
filesystem.
Returns None on success, or the OSError when the swap failed and out_dir
does not hold the intended content. Concurrent renderers can race here:
rendering is deterministic, so a lost race whose winner already published
byte-identical content is success. Every other failure is returned for
main() to HALT on — fail early with the real error rather than dispatch
a render known to be stale. Best effort on the way out: the last-good
render is restored for the next run when possible."""
trash_dir = staging_dir + ".prev"
moved_old = False
swap_error = None
if os.path.isdir(out_dir):
try:
os.rename(out_dir, trash_dir)
moved_old = True
except OSError as error:
swap_error = error
if swap_error is None:
try:
os.rename(staging_dir, out_dir)
except OSError as error:
swap_error = error
if swap_error is None:
if moved_old:
shutil.rmtree(trash_dir, ignore_errors=True)
return None
if _same_rendered_content(out_dir, staging_dir, filenames):
# Lost the deterministic race: a concurrent renderer already
# published equivalent content — this run's copy is redundant.
shutil.rmtree(staging_dir, ignore_errors=True)
if moved_old:
shutil.rmtree(trash_dir, ignore_errors=True)
return None
shutil.rmtree(staging_dir, ignore_errors=True)
if moved_old and not os.path.isdir(out_dir):
# Nobody published. Restore the previous render rather than leaving
# nothing — the next run may succeed where this one could not.
try:
os.rename(trash_dir, out_dir)
except OSError:
pass
return swap_error
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
skill_name = os.path.basename(script_dir)
root = find_project_root()
root = root.replace(os.sep, "/")
vars_, unsupported = 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
# Guarded ahead of the general missing-vars scan: sprint_status and
# deferred_work_file derive from it below, and unlike the scan (absent
# keys only) this also HALTs on a present-but-empty value.
implementation_artifacts = vars_.get("implementation_artifacts", "").strip()
if not implementation_artifacts:
print(
"HALT and report to the user: config is missing `implementation_artifacts` "
"(expected under [core] or [modules.bmm] in _bmad/config.toml)"
)
sys.exit(1)
vars_["sprint_status"] = posixpath.join(
implementation_artifacts, "sprint-status.yaml"
)
vars_["deferred_work_file"] = posixpath.join(
implementation_artifacts, "deferred-work.md"
)
sources = []
try:
listing = sorted(os.listdir(script_dir))
except OSError as error:
print(
f"HALT and report to the user: failed to list skill sources in {script_dir}: {error}"
)
sys.exit(1)
for fname in listing:
if not fname.endswith(".md") or fname == "SKILL.md":
continue
try:
with open(
posixpath.join(script_dir, fname), "r", encoding="utf-8", newline=""
) as fh:
sources.append((fname, fh.read()))
except (OSError, UnicodeDecodeError) as error:
print(
f"HALT and report to the user: failed to read skill source {fname}: {error}"
)
sys.exit(1)
missing = collect_missing_vars(sources, vars_)
if missing:
absent = "; ".join(
f"`{name}` (referenced by {', '.join(files)})"
for name, files in sorted(missing.items())
if name not in unsupported
)
typed = "; ".join(
f"`{name}` has unsupported type {unsupported[name]} — expected a string "
f"(referenced by {', '.join(files)})"
for name, files in sorted(missing.items())
if name in unsupported
)
parts = []
if absent:
parts.append(f"config is missing {absent}")
if typed:
parts.append(f"config value for {typed}")
print(
f"HALT and report to the user: {'; '.join(parts)} "
"(expected under [core] or [modules.bmm] in _bmad/config.toml)"
)
sys.exit(1)
empty = collect_empty_vars(sources, vars_)
if empty:
details = "; ".join(
f"`{name}` (referenced by {', '.join(files)})"
for name, files in sorted(empty.items())
)
print(
f"HALT and report to the user: config value is empty for {details} "
"— set a non-empty value under [core] or [modules.bmm] in _bmad/config.toml"
)
sys.exit(1)
workflow = resolve_workflow(root, script_dir.replace(os.sep, "/"), skill_name)
missing_workflow = collect_missing_workflow_keys(sources, workflow)
if missing_workflow:
details = "; ".join(
f"`workflow.{name}` (referenced by {', '.join(files)})"
for name, files in sorted(missing_workflow.items())
)
print(
f"HALT and report to the user: [workflow] customization is missing {details} "
f"(expected in {skill_name}/customize.toml or its overrides under _bmad/custom/)"
)
sys.exit(1)
for key in _REVIEW_LAYER_KEYS:
value = workflow.get(key)
if value is None:
continue
if not isinstance(value, list):
print(
f"HALT and report to the user: [workflow] customization key `{key}` "
f"must be an array of [[workflow.{key}]] tables, got {type(value).__name__}"
f"check {skill_name}/customize.toml and its overrides under _bmad/custom/"
)
sys.exit(1)
for layer in value:
fields = ("id", "name", "when", "instruction")
if not isinstance(layer, dict) or not all(
_is_scalar(layer.get(field)) for field in fields
):
print(
f"HALT and report to the user: [workflow] customization key `{key}` "
f"entries must be [[workflow.{key}]] tables with scalar "
f"id/name/when/instruction fields — "
f"check {skill_name}/customize.toml and its overrides under _bmad/custom/"
)
sys.exit(1)
referenced_workflow_keys = set()
for _, content in sources:
referenced_workflow_keys.update(re.findall(r"\{workflow\.(\w+)\}", content))
for name in sorted(referenced_workflow_keys):
if name in _REVIEW_LAYER_KEYS:
continue
value = workflow.get(name)
valid = _is_scalar(value) or (
isinstance(value, list) and all(_is_scalar(item) for item in value)
)
if not valid:
print(
f"HALT and report to the user: [workflow] customization key `{name}` "
f"must be a scalar or an array of scalars, got {type(value).__name__}"
f"check {skill_name}/customize.toml and its overrides under _bmad/custom/"
)
sys.exit(1)
render_root = posixpath.join(root, "_bmad", "render")
out_dir = posixpath.join(render_root, skill_name)
filenames = [fname for fname, _ in sources]
try:
os.makedirs(render_root, exist_ok=True)
staging_dir = tempfile.mkdtemp(prefix=f"{skill_name}.", dir=render_root)
except OSError as error:
print(
f"HALT and report to the user: failed to create a staging directory in {render_root}: {error}"
)
sys.exit(1)
publish_error = None
try:
for fname, content in sources:
dst = posixpath.join(staging_dir, fname)
rendered = render_workflow(render_template(content, vars_), workflow)
with open(dst, "w", encoding="utf-8", newline="") as fh:
fh.write(_absolutize_sibling_refs(rendered, out_dir, filenames))
if _same_rendered_content(out_dir, staging_dir, filenames):
shutil.rmtree(staging_dir, ignore_errors=True)
else:
publish_error = _publish_atomically(out_dir, staging_dir, filenames)
except OSError as error:
shutil.rmtree(staging_dir, ignore_errors=True)
print(
f"HALT and report to the user: failed to write the staged render: {error}"
)
sys.exit(1)
except BaseException:
shutil.rmtree(staging_dir, ignore_errors=True)
raise
if publish_error is not None:
print(
"HALT and report to the user: render publish failed — could not swap "
f"the staged render into {out_dir}: {publish_error}"
)
sys.exit(1)
workflow_md = posixpath.join(out_dir, "workflow.md")
if not os.path.isfile(workflow_md):
print(
f"HALT and report to the user: render publish failed — {workflow_md} does not exist"
)
sys.exit(1)
print(f"read and follow {workflow_md}")
if __name__ == "__main__":
main()
@@ -1,5 +1,4 @@
---
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
spec_file: '' # set at runtime once a route resolves it; some HALT branches exit before it is set
spec_folder: '' # set at runtime under folder+id dispatch only
story_id: '' # set at runtime under folder+id dispatch only
@@ -9,7 +8,7 @@ story_id: '' # set at runtime under folder+id dispatch only
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- **Language** — Speak in `{{.communication_language}}`. Write any file output in `{{.document_output_language}}`.
- Treat the invocation intent as workflow input, not as a substitute for step-02 investigation and spec generation.
- **EARLY EXIT** means: stop this step immediately, then read and follow the target file. Return here only if a later step explicitly says to loop back.
@@ -44,7 +43,7 @@ If the invocation prompt does not contain enough intent to identify what to impl
## INSTRUCTIONS
1. Load context.
- List files in `{planning_artifacts}` and `{implementation_artifacts}`.
- List files in `{{.planning_artifacts}}` and `{{.implementation_artifacts}}`.
- If the invocation prompt points to an unformatted spec or intent file, ingest that file. Do not scan for unrelated intent files.
- **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.
@@ -52,15 +51,15 @@ If the invocation prompt does not contain enough intent to identify what to impl
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-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> 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-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> 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.).
- **If missing, empty, or invalid:** compile it in the next bullet.
3. **Compile epic context if needed.** If no valid cached epic context was loaded, produce `{implementation_artifacts}/epic-<N>-context.md` by spawning a subagent synchronously (wait for it to return in this turn) 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-<N>-context.md`.
3. **Compile epic context if needed.** If no valid cached epic context was loaded, produce `{{.implementation_artifacts}}/epic-<N>-context.md` by spawning a subagent synchronously (wait for it to return in this turn) 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-<N>-context.md`.
4. **Verify if compiled.** If epic context was compiled, verify the output file exists, is non-empty, and starts with `# Epic <N> Context:`. If valid, load it. If verification fails, HALT with status `blocked` and blocking condition `context compilation verification failed`.
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, HALT with status `blocked` and blocking condition `missing previous-story continuity decision`.
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, HALT with status `blocked` and blocking condition `missing previous-story continuity decision`.
**B) Freeform path** — if the intent is not an epic story:
- Planning artifacts are the output of BMAD phases 1-3. Typical files include:
@@ -75,9 +74,9 @@ If the invocation prompt does not contain enough intent to identify what to impl
4. Multi-goal warning. If the intent appears to contain multiple independently shippable goals, carry `multiple-goals` forward so step-02 can add it to `{spec_file}` frontmatter `warnings`. Do not split or block.
5. Route:
**Folder+id dispatch:** derive a valid kebab-case slug from the entry's `title` (and `description` if needed) — the same kebab-casing convention as below, but never prefixed with `{story_id}`, since the id is already the filename's separate leading segment. Set `spec_file` = `{spec_folder}/stories/{story_id}-{slug}.md`. The id already disambiguates: no `{implementation_artifacts}` fallback, no `-2`/`-3` suffixing.
**Folder+id dispatch:** derive a valid kebab-case slug from the entry's `title` (and `description` if needed) — the same kebab-casing convention as below, but never prefixed with `{story_id}`, since the id is already the filename's separate leading segment. Set `spec_file` = `{spec_folder}/stories/{story_id}-{slug}.md`. The id already disambiguates: no `{{.implementation_artifacts}}` fallback, no `-2`/`-3` suffixing.
**Otherwise:** 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`.
**Otherwise:** 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`.
## NEXT
@@ -1,26 +1,22 @@
---
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}`
- **Language** — Speak in `{{.communication_language}}`. Write any file output in `{{.document_output_language}}`.
- No human interaction: do not ask questions or wait for approval in this step.
## INSTRUCTIONS
1. Draft resume check. If `{spec_file}` exists with `status: draft`, read it and capture the verbatim `<intent-contract>...</intent-contract>` block as `preserved_intent_contract`. Otherwise `preserved_intent_contract` is empty.
2. Investigate codebase. _Read the code yourself for narrow, localized tasks. Isolate deep exploration in synchronous subagents: instruct them to give you distilled summaries only, and plan from those summaries._
3. Read `./spec-template.md` fully. Fill it out based on the intent and investigation. If `{preserved_intent_contract}` is non-empty, substitute it for the `<intent-contract>` block in your filled spec before writing. Write the result to `{spec_file}`.
3. Read `./spec-template.md` fully. Fill it out based on the intent and investigation, resolving the template's `date` field to the current system date. If `{preserved_intent_contract}` is non-empty, substitute it for the `<intent-contract>` block in your filled spec before writing. Write the result to `{spec_file}`.
4. Self-review against READY FOR DEVELOPMENT standard.
5. If intent gaps exist, do not fantasize and do not leave open questions. Multiple defensible readings of the intent that lead to observably different outcomes, with nothing in the intent to select between them, are an intent gap — do not resolve one by picking a reading. HALT with status `blocked`, blocking condition `intent gap`, and include the unanswered questions and evidence gathered.
6. Warning check. If step-01 carried `multiple-goals`, add it to `{spec_file}` frontmatter `warnings`. If `{spec_file}` exceeds 1600 tokens, add `oversized` to frontmatter `warnings`. Continue either way.
### READY-FOR-DEVELOPMENT GATE
Re-read `./SKILL.md`, then re-read `{spec_file}` from disk and verify the spec meets the READY FOR DEVELOPMENT standard.
Re-read `./workflow.md`, then re-read `{spec_file}` from disk and verify the spec meets the READY FOR DEVELOPMENT standard.
- **If the file is missing:** HALT with status `blocked` and blocking condition `planned spec file disappeared before implementation`.
- **If the spec meets the standard:** set `{spec_file}` frontmatter status to `ready-for-dev`. If the invocation prompt directs a halt after planning (standard phrasing: `Halt after planning.` — accept any clear equivalent), HALT with status `ready-for-dev`; otherwise continue to step 3.
@@ -5,7 +5,7 @@
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- **Language** — Speak in `{{.communication_language}}`. Write any file output in `{{.document_output_language}}`.
- No human interaction: do not ask questions or wait for approval in this step.
- Content inside `<intent-contract>` in `{spec_file}` is read-only. Do not modify.
@@ -23,9 +23,11 @@ Capture `baseline_revision` (current HEAD, or `NO_VCS` if version control is una
Change `{spec_file}` status to `in-progress` in the frontmatter before starting implementation.
The implementation handoff is `{workflow.implementation_handoff}`, resolved during activation. Substitute the runtime placeholders (e.g. `{spec_file}`) into it, then follow it verbatim. Do not add parent-authored goal restatements, file lists, ownership boundaries, or acceptance criteria to the handoff — the spec is the subagent's sole source of truth. If the resolved handoff conflicts with the spec, HALT with status `blocked` and blocking condition `handoff conflicts with spec`, and include both conflicting passages.
Substitute the runtime placeholders (e.g. `{spec_file}`) into the implementation handoff below, then follow it verbatim. Do not add parent-authored goal restatements, file lists, ownership boundaries, or acceptance criteria to the handoff — the spec is the subagent's sole source of truth. If the handoff conflicts with the spec, HALT with status `blocked` and blocking condition `handoff conflicts with spec`, and include both conflicting passages.
Invoke the subagent **synchronously** and wait for it to return in this same turn — do not background/detach it (`run_in_background`) or end your turn to await a notification (see SKILL.md → Subagents). Resume at "Verify" only after it returns. If the platform allows, keep the subagent available for re-engagement after it returns — step-04 may send it review fixes.
{workflow.implementation_handoff}
Invoke the subagent **synchronously** and wait for it to return in this same turn — do not background/detach it (`run_in_background`) or end your turn to await a notification (see workflow.md → Subagents). Resume at "Verify" only after it returns. If the platform allows, keep the subagent available for re-engagement after it returns — step-04 may send it review fixes.
**Path formatting rule:** Any markdown links written into `{spec_file}` must use paths relative to `{spec_file}`'s directory so they are clickable in VS Code. Any file paths displayed in terminal/conversation output must use CWD-relative format with `:line` notation (e.g., `src/path/file.ts:42`) for terminal clickability. No leading `/` in either case.
@@ -1,12 +1,8 @@
---
deferred_work_file: '{implementation_artifacts}/deferred-work.md'
---
# Step 4: Review
## RULES
- YOU MUST ALWAYS SPEAK OUTPUT in your Agent communication style with the config `{communication_language}`
- **Language** — Speak in `{{.communication_language}}`. Write any file output in `{{.document_output_language}}`.
- No human interaction: do not ask questions or wait for approval in this step.
- All review subagents must run at the same model capability as the current session.
@@ -22,13 +18,11 @@ Do NOT `git add` anything — this is read-only inspection.
### Review
The review layers are `{workflow.review_layers}`, resolved during activation.
Skip every layer whose `instruction` is empty or missing — that is how an override disables a default layer — and every layer whose `when` condition (if present) does not hold in the current context. If no layers remain, HALT with status `blocked` and blocking condition `no active review layers`.
Runtime placeholders: `{diff_output}` is the diff constructed above. `{verbatim_intent}` is the invocation intent exactly as this run received it at step-01; if the run started from an existing spec file rather than a fresh intent, it is the spec's `<intent-contract>` block instead.
Execute all remaining layers in parallel wherever their execution methods allow: substitute the runtime placeholders (e.g. `{diff_output}`) into each layer's `instruction`, then follow it verbatim. Parallel means several blocking calls awaited together in this turn — never backgrounded or detached, never ending the turn to await results (see SKILL.md → Subagents). Spawn every reviewer subagent before reading or reacting to any of their output; begin collection and triage only once all are launched.
Execute these review layers in parallel wherever their execution methods allow: substitute the runtime placeholders (e.g. `{diff_output}`) into each layer's instruction, then follow it verbatim. Parallel means several blocking calls awaited together in this turn — never backgrounded or detached, never ending the turn to await results (see workflow.md → Subagents). Spawn every reviewer subagent before reading or reacting to any of their output; begin collection and triage only once all are launched.
{workflow.review_layers}
### Classify
@@ -56,17 +50,17 @@ Execute all remaining layers in parallel wherever their execution methods allow:
- addressed_findings:
- `[high|medium|low]` `[patch|bad_spec]` <finding summary and action taken in this pass>
```
Where `count` is either just `0`, or total with breakdown by severity `N: (high Nhigh, medium Nmedium, low Nlow)`.
Where `{date}` is the current system date and `count` is either just `0`, or total with breakdown by severity `N: (high Nhigh, medium Nmedium, low Nlow)`.
If no patch was fixed and no bad_spec repair loopback was triggered in this pass, write:
```markdown
- addressed_findings:
- none
```
5. Process findings in cascading order. If intent_gap exists, lower findings are moot; follow the intent_gap branch below. If bad_spec exists, lower findings are moot since code will be re-derived. If neither exists, process patch and defer normally. Before each bad_spec loopback, read `{spec_file}` frontmatter `review_loop_iteration` (missing means `0`), increment it by 1, and write it back. If it exceeds 5, append the triage-log entry for this pass with `addressed_findings: none`, then HALT with status `blocked` and blocking condition `review repair loop exceeded 5 iterations (non-convergence)`.
- **intent_gap** — Root cause is inside `<intent-contract>`. Save the attempted change as a patch file in `{implementation_artifacts}` and reference it from the triage-log entry, then revert code changes. Append the triage-log entry for this pass with `addressed_findings: none`, then HALT with status `blocked`, blocking condition `intent gap`, and include the unresolved questions and the saved patch path.
- **intent_gap** — Root cause is inside `<intent-contract>`. Save the attempted change as a patch file in `{{.implementation_artifacts}}` and reference it from the triage-log entry, then revert code changes. Append the triage-log entry for this pass with `addressed_findings: none`, then HALT with status `blocked`, blocking condition `intent gap`, and include the unresolved questions and the saved patch path.
- **bad_spec** — Root cause is outside `<intent-contract>`. Do not modify content inside `<intent-contract>`. 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 sections outside `<intent-contract>` 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. Append the triage-log entry for this pass, listing every bad_spec finding that triggered the spec amendment and implementation loopback under `addressed_findings`. 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. If the step-03 implementation subagent can be re-engaged with its context intact, send it all patch findings in one synchronous message — for each: the file, what is wrong, and what the fix must do. If it cannot be re-engaged, apply the patches yourself. Then re-run the commands in `{spec_file}`'s `## Verification` section (or perform its manual checks); if verification fails and the failure cannot be fixed, HALT with status `blocked` and blocking condition `patch verification failed`. Append the triage-log entry for this pass, listing every patch fixed in this pass under `addressed_findings`.
- **defer** — Append one new entry to `{deferred_work_file}` using this format. Do not modify existing entries or look for duplicates.
- **defer** — Append one new entry to `{{.deferred_work_file}}` using this format. Do not modify existing entries or look for duplicates.
```markdown
- source_spec: `{spec_file}`
summary: <one sentence>
@@ -0,0 +1,102 @@
# Dev Auto Workflow
**Goal:** Turn intent into a hardened, reviewable artifact, without human interaction.
**CRITICAL:** If a step says "read fully and follow step-XX", you read and follow step-XX. No exceptions.
## HALT
To HALT with a final status and optional blocking condition:
1. **Folder+id dispatch** (`{spec_folder}` and `{story_id}` are set): the write-back always lands at the id-keyed story spec. The `{{.implementation_artifacts}}` fallback in step 2 below is never used in this mode, even for halts before planning starts.
- If `{spec_file}` is still empty, resolve it now:
- **Entry not resolved** (`stories.yaml` is missing/unparseable, or `{story_id}` has no matching entry): use the fixed slug segment `unresolved`: `{spec_file}` = `{spec_folder}/stories/{story_id}-unresolved.md`.
- **Ambiguous on-disk match** (the halt is `ambiguous story file match` — more than one file already matches `{spec_folder}/stories/{story_id}-*.md`): use the fixed slug segment `ambiguous` instead of deriving from the title, so the write-back neither creates a third title-derived candidate nor risks silently landing on one of the existing ambiguous files: `{spec_file}` = `{spec_folder}/stories/{story_id}-ambiguous.md`.
- **Otherwise** (the entry was resolved and no ambiguous on-disk match exists): derive `{spec_file}` = `{spec_folder}/stories/{story_id}-{slug}.md`, where `{slug}` is a kebab-case slug from `title` (and `description` if needed) with no `{story_id}` prefix — the same derivation step-01's Route uses.
- If `{spec_file}` exists on disk, update `status` in frontmatter and append missing result details under `## Auto Run Result`.
- If it does not exist, create it as a skeletal story spec:
```markdown
---
status: <final status>
---
# <entry title, or "Story {story_id}" if the entry could not be resolved or the on-disk match was ambiguous>
## Auto Run Result
Status: <final status>
Blocking condition: <blocking condition, if any>
```
2. **Otherwise:**
- If `{spec_file}` is known and exists, update `status` in frontmatter and append missing result details under `## Auto Run Result`.
- If `{spec_file}` is unknown or missing, create `{{.implementation_artifacts}}/bmad-dev-auto-result-<slug-or-timestamp>.md` with:
```markdown
---
status: <final status>
---
# BMad Dev Auto Result
Status: <final status>
Blocking condition: <blocking condition, if any>
```
3. Follow **On Complete** below, then stop the workflow.
### On Complete
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
{workflow.on_complete}
## Subagents
Using subagents when instructed is mandatory. If you cannot, HALT with status `blocked` and blocking condition `no subagents`.
Invoke every subagent **synchronously**: launch it, wait for it to return within the same turn, then continue with its result. When a step says to run subagents "in parallel" (e.g. the reviewers), that means several **blocking** calls awaited together in one turn — not detached execution. Never run a subagent in the background / detached / async (e.g. `run_in_background: true`), and never end your turn to "await a completion notification." This workflow runs unattended: there is no event loop to resume a yielded turn, so a backgrounded subagent never hands control back and the run stalls. The only sanctioned way to end a turn is the HALT protocol above with an explicit terminal `status`.
## 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.
- **Surface-anchored**: ACs observe the outermost surface the intent references — never a more internal proxy for it (e.g. the API response, not the database row behind it).
- **Complete**: No placeholders or TBDs.
- **Sufficient**: No known requirement, acceptance, dependency, or implementation gaps remain unresolved.
- **Coherent**: No unresolved ambiguities or internal contradictions.
## Conventions
- Every cross-file reference in this workflow is an absolute path — open it directly. Do not resolve anything relative to this file or to any skill directory.
- `{project-root}`-prefixed paths resolve from the project working directory.
## On Activation
### Step 1: Execute Prepend Steps
Execute each of these steps in order before proceeding (`_None._` means skip):
{workflow.activation_steps_prepend}
### Step 2: Load Persistent Facts
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):
{workflow.persistent_facts}
### Step 3: Execute Append Steps
Execute each of these steps in order (`_None._` means skip):
{workflow.activation_steps_append}
Activation is complete after all activation steps have run.
## Workflow Execution
Follow the step files in order. Read one step fully, execute it, then load the next step only when directed. Do not skip, reorder, or pre-load steps.
## First workflow step
Read fully and follow: `./step-01-clarify-and-route.md` to begin the workflow.
@@ -6,7 +6,7 @@ description: 'Implements any user intent, requirement, story, bug fix or change
Run this, substituting `{skill-root}` with the absolute path to this skill's base directory, without changing the cwd:
```bash
uv run {skill-root}/render.py
uv run "{skill-root}/render.py"
```
- **On success:** follow the instruction it prints to stdout; ignore stderr.
@@ -6,16 +6,18 @@
Resolves compile-time {{.variable}} placeholders from BMad's central config,
bakes absolute paths for {project-root} into derived values, resolves and
inlines the skill's [workflow] customization block, and writes rendered .md
inlines the skill's [workflow] customization block, rewrites ./<sibling>.md
cross-references to their absolute publish paths, 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. A {{.var}} referenced by this skill's .md sources but
absent from the merged config → HALT (never a silent empty substitution).
Optional layers may be missing, but one that exists and cannot be parsed
or read → HALT.
absent from the merged config, present with an empty value, or present
with a non-string type → HALT (never a silent empty or garbage
substitution). Optional layers may be missing, but one that exists and
cannot be parsed or read → HALT.
Customization: three-layer merge of {skill}/customize.toml +
_bmad/custom/bmad-quick-dev.toml + .user.toml (same structural rules as
@@ -24,14 +26,23 @@ 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.
Every invocation rebuilds from scratch and republishes atomically: rendering
is deterministic (same _bmad config + same skill source -> same output), so
the result is staged in a sibling temp dir and swapped into place only if it
differs from what's already published — a no-op the vast majority of the
time. This keeps concurrent renderers (parallel agents, or worktrees sharing
one _bmad/ via symlink) from tearing each other's output: readers only ever
see a fully-old or fully-new publish, never a half-written one. See
_publish_atomically for the swap and its handling of the residual race.
Python 3.11+ stdlib only. UTF-8 I/O.
"""
import os
import posixpath
import re
import shutil
import sys
import tempfile
import tomllib
@@ -188,8 +199,12 @@ def load_central_config(root):
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)."""
module buckets, so collisions shouldn't happen in practice). Also returns
the keys present with non-scalar values (arrays, tables, dates), so the
missing-vars HALT can name the actual problem instead of claiming a key
the user can see in their config is absent."""
flat = {}
unsupported = {}
modules = merged.get("modules")
modules = modules if isinstance(modules, dict) else {}
for section in (merged.get("core"), modules.get("bmm")):
@@ -198,9 +213,14 @@ def flatten_central_config(merged):
for key, value in section.items():
if isinstance(value, bool):
flat[key] = "true" if value else "false"
unsupported.pop(key, None)
elif isinstance(value, (str, int, float)):
flat[key] = str(value)
return flat
unsupported.pop(key, None)
else:
unsupported[key] = type(value).__name__
flat.pop(key, None)
return flat, unsupported
def render_template(content, vars_):
@@ -225,6 +245,39 @@ def collect_missing_vars(sources, vars_):
return missing
def collect_empty_vars(sources, vars_):
"""Map each {{.var}} referenced by the source .md files whose merged-config
value is present but empty/whitespace to the files that reference it. Every
referenced key names something the workflow cannot function without (paths,
languages), so an empty value is always a config mistake — HALT rather than
bake blank prose into the rendered workflow."""
empty = {}
for fname, content in sources:
for name in re.findall(r"\{\{\.(\w+)\}\}", content):
if name in vars_ and not vars_[name].strip():
files = empty.setdefault(name, [])
if fname not in files:
files.append(fname)
return empty
def collect_missing_workflow_keys(sources, workflow):
"""Map each {workflow.<key>} name referenced by the source .md files but
absent from the resolved [workflow] block to the files that reference it.
A missing key must HALT: missingkey=zero rendering would bake an empty
handoff or review block into the workflow with no failure signal. Every
legitimate key ships in this skill's customize.toml defaults, so a miss
is always an authoring error (typo) or a broken install."""
missing = {}
for fname, content in sources:
for name in re.findall(r"\{workflow\.(\w+)\}", content):
if name not in workflow:
files = missing.setdefault(name, [])
if fname not in files:
files.append(fname)
return missing
def _scalar_str(value):
"""Stringify a scalar for inline rendering: booleans lowercase (matching
BMad config conventions), None as empty, everything else via str()."""
@@ -235,6 +288,13 @@ def _scalar_str(value):
return str(value)
def _is_scalar(value):
"""True for values _scalar_str can render faithfully. Anything else (a
table, an array, a date) would bake its Python repr into the rendered
workflow — main() HALTs on those instead."""
return value is None or isinstance(value, (bool, str, int, float))
# [workflow] keys holding review layers ([[workflow.review_layers]] tables with
# id/name/instruction/when fields). This renderer knows this skill's
# customization schema outright — layer semantics are materialized here, not
@@ -297,9 +357,11 @@ def _render_workflow_value(key, value):
def render_workflow(content, workflow):
"""Resolve {workflow.<key>} 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."""
Unknown keys emit an empty string, but main() HALTs on any missing key
before rendering starts (collect_missing_workflow_keys), so this fallback
never fires in practice — 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(m.group(1), workflow.get(m.group(1))),
@@ -307,13 +369,106 @@ def render_workflow(content, workflow):
)
def _absolutize_sibling_refs(content, out_dir, filenames):
"""Rewrite ./<name>.md cross-references to their canonical absolute path
in the publish directory. Only rendered siblings are rewritten; any other
relative .md mention passes through untouched. The executor then never
resolves a relative path — every cross-file reference is absolute:
readable, or loudly missing. Never a silent hit on the unrendered
sources in the skill directory."""
rendered = set(filenames)
return re.sub(
r"\./([\w-]+\.md)",
lambda m: (
posixpath.join(out_dir, m.group(1))
if m.group(1) in rendered
else m.group(0)
),
content,
)
def _same_rendered_content(existing_dir, staging_dir, filenames):
"""True if existing_dir already holds exactly filenames with byte-identical
content to staging_dir. Missing/unreadable existing_dir, or any mismatch,
means "not the same" — always err toward publishing."""
if not os.path.isdir(existing_dir):
return False
try:
existing_names = {f for f in os.listdir(existing_dir) if f.endswith(".md")}
except OSError:
return False
if existing_names != set(filenames):
return False
for fname in filenames:
try:
with open(posixpath.join(existing_dir, fname), "rb") as a, open(
posixpath.join(staging_dir, fname), "rb"
) as b:
if a.read() != b.read():
return False
except OSError:
return False
return True
def _publish_atomically(out_dir, staging_dir, filenames):
"""Swap staging_dir into place at out_dir via same-directory renames,
which are atomic on a given filesystem — readers of out_dir see either the
fully-old or fully-new publish, never a half-written one. staging_dir
lives alongside out_dir (same parent), so both renames stay on the same
filesystem.
Returns None on success, or the OSError when the swap failed and out_dir
does not hold the intended content. Concurrent renderers can race here:
rendering is deterministic, so a lost race whose winner already published
byte-identical content is success. Every other failure is returned for
main() to HALT on — fail early with the real error rather than dispatch
a render known to be stale. Best effort on the way out: the last-good
render is restored for the next run when possible."""
trash_dir = staging_dir + ".prev"
moved_old = False
swap_error = None
if os.path.isdir(out_dir):
try:
os.rename(out_dir, trash_dir)
moved_old = True
except OSError as error:
swap_error = error
if swap_error is None:
try:
os.rename(staging_dir, out_dir)
except OSError as error:
swap_error = error
if swap_error is None:
if moved_old:
shutil.rmtree(trash_dir, ignore_errors=True)
return None
if _same_rendered_content(out_dir, staging_dir, filenames):
# Lost the deterministic race: a concurrent renderer already
# published equivalent content — this run's copy is redundant.
shutil.rmtree(staging_dir, ignore_errors=True)
if moved_old:
shutil.rmtree(trash_dir, ignore_errors=True)
return None
shutil.rmtree(staging_dir, ignore_errors=True)
if moved_old and not os.path.isdir(out_dir):
# Nobody published. Restore the previous render rather than leaving
# nothing — the next run may succeed where this one could not.
try:
os.rename(trash_dir, out_dir)
except OSError:
pass
return swap_error
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
skill_name = os.path.basename(script_dir)
root = find_project_root()
root = root.replace(os.sep, "/")
vars_ = flatten_central_config(load_central_config(root))
vars_, unsupported = flatten_central_config(load_central_config(root))
for key in list(vars_.keys()):
vars_[key] = vars_[key].replace("{project-root}", root)
@@ -339,41 +494,165 @@ def main():
)
sources = []
for fname in sorted(os.listdir(script_dir)):
try:
listing = sorted(os.listdir(script_dir))
except OSError as error:
print(
f"HALT and report to the user: failed to list skill sources in {script_dir}: {error}"
)
sys.exit(1)
for fname in listing:
if not fname.endswith(".md") or fname == "SKILL.md":
continue
with open(
posixpath.join(script_dir, fname), "r", encoding="utf-8", newline=""
) as fh:
sources.append((fname, fh.read()))
try:
with open(
posixpath.join(script_dir, fname), "r", encoding="utf-8", newline=""
) as fh:
sources.append((fname, fh.read()))
except (OSError, UnicodeDecodeError) as error:
print(
f"HALT and report to the user: failed to read skill source {fname}: {error}"
)
sys.exit(1)
missing = collect_missing_vars(sources, vars_)
if missing:
details = "; ".join(
absent = "; ".join(
f"`{name}` (referenced by {', '.join(files)})"
for name, files in sorted(missing.items())
if name not in unsupported
)
typed = "; ".join(
f"`{name}` has unsupported type {unsupported[name]} — expected a string "
f"(referenced by {', '.join(files)})"
for name, files in sorted(missing.items())
if name in unsupported
)
parts = []
if absent:
parts.append(f"config is missing {absent}")
if typed:
parts.append(f"config value for {typed}")
print(
f"HALT and report to the user: {'; '.join(parts)} "
"(expected under [core] or [modules.bmm] in _bmad/config.toml)"
)
sys.exit(1)
empty = collect_empty_vars(sources, vars_)
if empty:
details = "; ".join(
f"`{name}` (referenced by {', '.join(files)})"
for name, files in sorted(empty.items())
)
print(
f"HALT and report to the user: config is missing {details} "
"(expected under [core] or [modules.bmm] in _bmad/config.toml)"
f"HALT and report to the user: config value is empty for {details} "
"— set a non-empty value under [core] or [modules.bmm] in _bmad/config.toml"
)
sys.exit(1)
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)
missing_workflow = collect_missing_workflow_keys(sources, workflow)
if missing_workflow:
details = "; ".join(
f"`workflow.{name}` (referenced by {', '.join(files)})"
for name, files in sorted(missing_workflow.items())
)
print(
f"HALT and report to the user: [workflow] customization is missing {details} "
f"(expected in {skill_name}/customize.toml or its overrides under _bmad/custom/)"
)
sys.exit(1)
for fname in os.listdir(out_dir):
if fname.endswith(".md"):
os.remove(posixpath.join(out_dir, fname))
for key in _REVIEW_LAYER_KEYS:
value = workflow.get(key)
if value is None:
continue
if not isinstance(value, list):
print(
f"HALT and report to the user: [workflow] customization key `{key}` "
f"must be an array of [[workflow.{key}]] tables, got {type(value).__name__}"
f"check {skill_name}/customize.toml and its overrides under _bmad/custom/"
)
sys.exit(1)
for layer in value:
fields = ("id", "name", "when", "instruction")
if not isinstance(layer, dict) or not all(
_is_scalar(layer.get(field)) for field in fields
):
print(
f"HALT and report to the user: [workflow] customization key `{key}` "
f"entries must be [[workflow.{key}]] tables with scalar "
f"id/name/when/instruction fields — "
f"check {skill_name}/customize.toml and its overrides under _bmad/custom/"
)
sys.exit(1)
for fname, content in sources:
dst = posixpath.join(out_dir, fname)
with open(dst, "w", encoding="utf-8", newline="") as fh:
fh.write(render_workflow(render_template(content, vars_), workflow))
referenced_workflow_keys = set()
for _, content in sources:
referenced_workflow_keys.update(re.findall(r"\{workflow\.(\w+)\}", content))
for name in sorted(referenced_workflow_keys):
if name in _REVIEW_LAYER_KEYS:
continue
value = workflow.get(name)
valid = _is_scalar(value) or (
isinstance(value, list) and all(_is_scalar(item) for item in value)
)
if not valid:
print(
f"HALT and report to the user: [workflow] customization key `{name}` "
f"must be a scalar or an array of scalars, got {type(value).__name__}"
f"check {skill_name}/customize.toml and its overrides under _bmad/custom/"
)
sys.exit(1)
render_root = posixpath.join(root, "_bmad", "render")
out_dir = posixpath.join(render_root, skill_name)
filenames = [fname for fname, _ in sources]
try:
os.makedirs(render_root, exist_ok=True)
staging_dir = tempfile.mkdtemp(prefix=f"{skill_name}.", dir=render_root)
except OSError as error:
print(
f"HALT and report to the user: failed to create a staging directory in {render_root}: {error}"
)
sys.exit(1)
publish_error = None
try:
for fname, content in sources:
dst = posixpath.join(staging_dir, fname)
rendered = render_workflow(render_template(content, vars_), workflow)
with open(dst, "w", encoding="utf-8", newline="") as fh:
fh.write(_absolutize_sibling_refs(rendered, out_dir, filenames))
if _same_rendered_content(out_dir, staging_dir, filenames):
shutil.rmtree(staging_dir, ignore_errors=True)
else:
publish_error = _publish_atomically(out_dir, staging_dir, filenames)
except OSError as error:
shutil.rmtree(staging_dir, ignore_errors=True)
print(
f"HALT and report to the user: failed to write the staged render: {error}"
)
sys.exit(1)
except BaseException:
shutil.rmtree(staging_dir, ignore_errors=True)
raise
if publish_error is not None:
print(
"HALT and report to the user: render publish failed — could not swap "
f"the staged render into {out_dir}: {publish_error}"
)
sys.exit(1)
workflow_md = posixpath.join(out_dir, "workflow.md")
if not os.path.isfile(workflow_md):
print(
f"HALT and report to the user: render publish failed — {workflow_md} does not exist"
)
sys.exit(1)
print(f"read and follow {workflow_md}")
@@ -30,10 +30,8 @@ A specification should target a **single user-facing goal** within **9001600
## 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).
- Every cross-file reference in this workflow is an absolute path — open it directly. Do not resolve anything relative to this file or to any skill directory.
- `{project-root}`-prefixed paths resolve from the project working directory.
- `{skill-name}` resolves to the skill directory's basename.
## On Activation
+642
View File
@@ -0,0 +1,642 @@
/**
* Smoke test for bmad-dev-auto render.py
*
* Sets up a temp project with base + override config layers and a
* _bmad/custom/bmad-dev-auto.user.toml [workflow] override, runs render.py,
* and asserts:
* 1. render.py stays in sync with bmad-quick-dev's (skill-name refs aside) —
* the renderer is deliberately duplicated per skill, so drift is a bug.
* 2. The central-config override wins (step files' language line contains
* "Japanese") and paths bake absolute into the rendered output.
* 3. [workflow] customization is self-resolved and inlined: prepend bullet,
* persistent_facts append (base kept), empty list -> _None._, on_complete
* scalar baked into workflow.md, implementation_handoff baked into
* step-03 with runtime {spec_file} surviving.
* 4. Review layers materialize as direct invocation blocks in step-04: an
* override replacing a layer by id wins, an empty-instruction override
* drops its layer, a `when` renders as a run-time guard, runtime
* placeholders like {diff_output}/{verbatim_intent} survive, and
* disabling every layer renders the HALT instruction.
* 5. No {workflow.*} placeholder or resolve_customization.py call survives
* in any rendered file.
*
* Usage: node test/test-dev-auto-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-dev-auto');
const QUICK_DEV_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);
}
}
}
// Extra one-off temp projects created by makeProject(); cleaned up in finally.
const extraTmpDirs = [];
/**
* Spin up an isolated temp project with the given _bmad/config.toml body and a
* copy of the skill dir, so a single bad-config scenario can be rendered in
* isolation. Returns { dir, skillDst }; the caller runs render.py against it.
*/
function makeProject(configText) {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-dev-auto-renderer-halt-'));
extraTmpDirs.push(dir);
fs.mkdirSync(path.join(dir, '_bmad'), { recursive: true });
fs.writeFileSync(path.join(dir, '_bmad', 'config.toml'), configText, 'utf-8');
const skillDst = path.join(dir, 'bmad-dev-auto');
copyDirSync(SKILL_SRC, skillDst);
return { dir, skillDst };
}
// ---------------------------------------------------------------------------
// Test fixture setup
// ---------------------------------------------------------------------------
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'bmad-dev-auto-renderer-test-'));
try {
console.log(`\n${colors.cyan}Dev-auto renderer smoke tests${colors.reset}\n`);
test('render.py stays in sync with the quick-dev renderer (skill-name refs aside)', () => {
const normalize = (source) =>
source
.replaceAll('bmad-quick-dev', '<skill>')
.replaceAll('bmad-dev-auto', '<skill>')
.replaceAll('quick-dev only reads', '<skill> only reads')
.replaceAll('dev-auto only reads', '<skill> only reads');
const devAuto = fs.readFileSync(path.join(SKILL_SRC, 'render.py'), 'utf-8');
const quickDev = fs.readFileSync(path.join(QUICK_DEV_SRC, 'render.py'), 'utf-8');
assert(
normalize(devAuto) === normalize(quickDev),
'bmad-dev-auto/render.py has drifted from bmad-quick-dev/render.py beyond skill-name references — propagate the change to both copies',
);
});
// _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"',
'document_output_language = "Klingon"',
'',
'[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',
);
// _bmad/custom/bmad-dev-auto.user.toml — [workflow] customization override.
// Exercises render.py's self-resolution: array append (persistent_facts),
// list inlining (activation_steps_prepend), scalar overrides (on_complete,
// implementation_handoff), and review-layer keyed merge, all baked into the
// rendered output with no runtime resolve_customization.py.
fs.writeFileSync(
path.join(tmpDir, '_bmad', 'custom', 'bmad-dev-auto.user.toml'),
[
'[workflow]',
'activation_steps_prepend = ["TEST_PREPEND_STEP"]',
'persistent_facts = ["TEST_EXTRA_FACT"]',
'on_complete = "TEST_ON_COMPLETE_INSTRUCTION"',
'implementation_handoff = "TEST_HANDOFF_INSTRUCTION for {spec_file}"',
'',
'[[workflow.review_layers]]',
'id = "edge-case-hunter"',
'name = "Replaced Layer"',
'when = "TEST_WHEN_CONDITION"',
'instruction = "TEST_REPLACED_LAYER_INSTRUCTION"',
'',
'[[workflow.review_layers]]',
'id = "verification-gap"',
'instruction = ""',
].join('\n'),
'utf-8',
);
// Copy skill dir into <tmpDir>/bmad-dev-auto/ so find_project_root() walks
// up and finds <tmpDir>/_bmad/, and os.path.basename(script_dir) resolves
// to the real skill name so the render output lands at
// _bmad/render/bmad-dev-auto/workflow.md.
const skillDst = path.join(tmpDir, 'bmad-dev-auto');
copyDirSync(SKILL_SRC, skillDst);
// ---------------------------------------------------------------------------
// Run render.py
// ---------------------------------------------------------------------------
const result = spawnSync('python3', [path.join(skillDst, 'render.py')], {
cwd: skillDst,
encoding: 'utf-8',
});
const renderDir = path.join(tmpDir, '_bmad', 'render', 'bmad-dev-auto');
const readRendered = (name) => fs.readFileSync(path.join(renderDir, name), 'utf-8');
const renderedMdFiles = () => fs.readdirSync(renderDir).filter((f) => f.endsWith('.md'));
// ---------------------------------------------------------------------------
// 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 and stdout dispatches to it', () => {
const rendered = path.join(renderDir, 'workflow.md');
assert(fs.existsSync(rendered), `workflow.md not found at ${rendered}`);
// realpathSync: on macOS os.tmpdir() is a symlink (/var -> /private/var)
// while the renderer bakes the physical path it gets from getcwd().
assert(
result.stdout.trim() === `read and follow ${fs.realpathSync(rendered)}`,
`stdout is not exactly the dispatch line to the rendered workflow.md.\nstdout: ${result.stdout}`,
);
});
test('identical re-render is a no-op that leaves the publish untouched', () => {
const workflowPath = fs.realpathSync(path.join(renderDir, 'workflow.md'));
const inodeBefore = fs.statSync(workflowPath).ino;
const contentBefore = fs.readFileSync(workflowPath, 'utf-8');
const rerun = spawnSync('python3', [path.join(skillDst, 'render.py')], {
cwd: skillDst,
encoding: 'utf-8',
});
assert(rerun.status === 0, `re-render exit code ${rerun.status}\nstdout: ${rerun.stdout}\nstderr: ${rerun.stderr}`);
assert(rerun.stdout.trim() === `read and follow ${workflowPath}`, `re-render dispatch line wrong.\nstdout: ${rerun.stdout}`);
assert(fs.readFileSync(workflowPath, 'utf-8') === contentBefore, 're-rendered workflow.md content changed on identical inputs');
assert(fs.statSync(workflowPath).ino === inodeBefore, 'identical re-render republished (new inode) instead of no-op skipping the swap');
});
test('custom override wins — communication_language baked into step files', () => {
const content = readRendered('step-01-clarify-and-route.md');
assert(content.includes('Japanese'), 'communication_language override (Japanese) did not win in the step-01 language line');
});
test('document_output_language bakes into the per-step language line', () => {
const content = readRendered('step-01-clarify-and-route.md');
assert(content.includes('Klingon'), 'document_output_language not baked into the step-01 language line');
});
test('deferred_work_file is an absolute path rooted at temp project dir', () => {
const content = readRendered('step-04-review.md');
// Normalize to forward slashes for cross-platform matching
const normalizedTmp = tmpDir.replaceAll('\\', '/');
const expected = `${normalizedTmp}/impl/deferred-work.md`;
assert(
content.includes(expected),
`deferred_work_file path not found.\nExpected substring: ${expected}\n` +
`step-04-review.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 the HALT On Complete section', () => {
assert(readRendered('workflow.md').includes('TEST_ON_COMPLETE_INSTRUCTION'), 'on_complete not inlined into workflow.md');
});
test('implementation_handoff inlined into step-03 with runtime {spec_file} surviving', () => {
const content = readRendered('step-03-implement.md');
assert(content.includes('TEST_HANDOFF_INSTRUCTION'), 'implementation_handoff override not inlined into step-03');
assert(content.includes('{spec_file}'), 'runtime {spec_file} placeholder did not survive rendering');
});
test('review layers materialize as invocation blocks in step-04', () => {
const content = readRendered('step-04-review.md');
assert(content.includes('#### Blind Hunter'), 'default review layer not rendered as a #### invocation block');
assert(content.includes('#### Intent Alignment Auditor'), 'intent-alignment layer not rendered as a #### invocation block');
assert(!content.includes('- id:'), 'layer table data leaked into the rendered output');
assert(content.includes('{diff_output}'), 'runtime {diff_output} placeholder did not survive rendering');
assert(content.includes('{verbatim_intent}'), 'runtime {verbatim_intent} placeholder did not survive rendering');
});
test('review layer override replaces the matching default by id', () => {
const content = readRendered('step-04-review.md');
assert(content.includes('#### Replaced Layer'), 'override layer name not used as block title');
assert(content.includes('TEST_REPLACED_LAYER_INSTRUCTION'), 'override layer instruction not inlined');
assert(!content.includes('bmad-review-edge-case-hunter'), 'replaced default layer instruction still present');
assert(content.includes('bmad-review-adversarial-general'), 'untouched default layer dropped by keyed merge');
});
test('empty-instruction override drops its layer entirely', () => {
const content = readRendered('step-04-review.md');
assert(!content.includes('verification-gap'), 'disabled layer id still present in rendered output');
assert(!content.includes('Verification Gap Reviewer'), 'disabled layer name still present in rendered output');
});
test('when condition renders as a run-time guard line', () => {
const content = readRendered('step-04-review.md');
assert(
content.includes('Run this layer only if the following holds in the current context: `TEST_WHEN_CONDITION`'),
'when condition not rendered as a guard line',
);
});
test('disabling every layer renders the HALT instruction', () => {
// Second render pass: replace the override file so every default layer is
// disabled, then re-render.
fs.writeFileSync(
path.join(tmpDir, '_bmad', 'custom', 'bmad-dev-auto.user.toml'),
[
'[workflow]',
'',
'[[workflow.review_layers]]',
'id = "blind-hunter"',
'instruction = ""',
'',
'[[workflow.review_layers]]',
'id = "edge-case-hunter"',
'instruction = ""',
'',
'[[workflow.review_layers]]',
'id = "verification-gap"',
'instruction = ""',
'',
'[[workflow.review_layers]]',
'id = "intent-alignment"',
'instruction = ""',
].join('\n'),
'utf-8',
);
const rerun = spawnSync('python3', [path.join(skillDst, 'render.py')], {
cwd: skillDst,
encoding: 'utf-8',
});
assert(rerun.status === 0, `re-render exit code ${rerun.status}\nstderr: ${rerun.stderr}`);
const halt = 'No review layers are active. HALT with status `blocked` and blocking condition `no active review layers`.';
assert(readRendered('step-04-review.md').includes(halt), 'HALT instruction missing from step-04-review.md');
});
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('sibling cross-references are absolute paths into the render dir', () => {
const physicalRenderDir = fs.realpathSync(renderDir);
assert(
readRendered('workflow.md').includes(`${physicalRenderDir}/step-01-clarify-and-route.md`),
'workflow.md dispatch to step-01 was not rewritten to an absolute render-dir path',
);
assert(
readRendered('step-02-plan.md').includes(`${physicalRenderDir}/spec-template.md`),
'step-02 reference to spec-template.md was not rewritten to an absolute render-dir path',
);
});
test('no relative ./*.md reference survives in any rendered file', () => {
const leaks = renderedMdFiles().filter((f) => /\.\/[\w-]+\.md/.test(readRendered(f)));
assert(leaks.length === 0, `relative ./*.md references leaked in: ${leaks.join(', ')}`);
});
test('SKILL.md is not rendered into the output dir', () => {
assert(
!fs.existsSync(path.join(renderDir, 'SKILL.md')),
'SKILL.md leaked into the render output — the dispatch stub must never be rendered',
);
});
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(', ')}`);
});
// ---------------------------------------------------------------------------
// Bad-config HALTs cleanly (never a raw Python traceback)
// ---------------------------------------------------------------------------
test('missing planning_artifacts HALTs cleanly (no traceback)', () => {
// implementation_artifacts is present, so this exercises the general
// missing-vars scan rather than the dedicated guard.
const { skillDst: dst } = makeProject(
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
);
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: config is missing') && res.stdout.includes('`planning_artifacts`'),
`stdout missing the planning_artifacts HALT directive.\nstdout: ${res.stdout}`,
);
assert(
res.stdout.includes('step-01-clarify-and-route.md'),
`HALT directive does not name the referencing file.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('unparseable customization override HALTs cleanly (no traceback)', () => {
const { dir, skillDst: dst } = makeProject(
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
);
fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true });
fs.writeFileSync(path.join(dir, '_bmad', 'custom', 'bmad-dev-auto.user.toml'), '[workflow\non_complete = broken', 'utf-8');
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: failed to parse') && res.stdout.includes('bmad-dev-auto.user.toml'),
`stdout missing the failed-to-parse HALT directive naming the override file.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('unknown {workflow.*} reference HALTs cleanly (no traceback)', () => {
const { skillDst: dst } = makeProject(
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
);
fs.appendFileSync(path.join(dst, 'workflow.md'), '\n{workflow.no_such_key}\n', 'utf-8');
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: [workflow] customization is missing') &&
res.stdout.includes('`workflow.no_such_key`'),
`stdout missing the unknown-workflow-key HALT directive.\nstdout: ${res.stdout}`,
);
assert(res.stdout.includes('workflow.md'), `HALT directive does not name the referencing file.\nstdout: ${res.stdout}`);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('non-array review_layers override HALTs cleanly (no traceback)', () => {
const { dir, skillDst: dst } = makeProject(
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
);
fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true });
fs.writeFileSync(path.join(dir, '_bmad', 'custom', 'bmad-dev-auto.user.toml'), '[workflow]\nreview_layers = "not a list"', 'utf-8');
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: [workflow] customization key `review_layers`') &&
res.stdout.includes('must be an array'),
`stdout missing the non-array review_layers HALT directive.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
const GOOD_CONFIG = [
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n');
test('present-but-empty config value HALTs cleanly (no traceback)', () => {
const { skillDst: dst } = makeProject(GOOD_CONFIG.replace('"{project-root}/plan"', '" "'));
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: config value is empty for') && res.stdout.includes('`planning_artifacts`'),
`stdout missing the empty-value HALT directive.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('unsupported-type config value is not misreported as missing (no traceback)', () => {
const { skillDst: dst } = makeProject(GOOD_CONFIG.replace('"{project-root}/plan"', '["a", "b"]'));
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('`planning_artifacts` has unsupported type list') && res.stdout.includes('expected a string'),
`stdout does not name the unsupported type.\nstdout: ${res.stdout}`,
);
assert(!res.stdout.includes('config is missing'), `a present key must not be reported as missing.\nstdout: ${res.stdout}`);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('non-scalar [workflow] value HALTs instead of baking Python repr (no traceback)', () => {
const { dir, skillDst: dst } = makeProject(GOOD_CONFIG);
fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true });
fs.writeFileSync(
path.join(dir, '_bmad', 'custom', 'bmad-dev-auto.user.toml'),
'[workflow.implementation_handoff]\ntext = "boom"',
'utf-8',
);
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('[workflow] customization key `implementation_handoff`') &&
res.stdout.includes('must be a scalar or an array of scalars'),
`stdout missing the non-scalar workflow-value HALT directive.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('non-scalar review-layer field HALTs cleanly (no traceback)', () => {
const { dir, skillDst: dst } = makeProject(GOOD_CONFIG);
fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true });
fs.writeFileSync(
path.join(dir, '_bmad', 'custom', 'bmad-dev-auto.user.toml'),
['[[workflow.review_layers]]', 'id = "blind-hunter"', 'instruction = ["not", "a", "string"]'].join('\n'),
'utf-8',
);
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('entries must be [[workflow.review_layers]] tables with scalar'),
`stdout missing the non-scalar layer-field HALT directive.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('unreadable (non-UTF-8) skill source HALTs cleanly (no traceback)', () => {
const { skillDst: dst } = makeProject(GOOD_CONFIG);
fs.appendFileSync(path.join(dst, 'step-02-plan.md'), Buffer.from([0xff, 0xfe, 0xfa]));
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: failed to read skill source step-02-plan.md'),
`stdout missing the unreadable-source HALT directive.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
// ---------------------------------------------------------------------------
// Publish-swap failure handling (injected os.rename errors)
// ---------------------------------------------------------------------------
test('failed publish swap HALTs and preserves the previous render', () => {
const { dir, skillDst: dst } = makeProject(GOOD_CONFIG);
const first = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(first.status === 0, `initial render failed: ${first.stdout}\n${first.stderr}`);
// Change config so a republish is required, then deny every rename.
fs.writeFileSync(path.join(dir, '_bmad', 'config.toml'), GOOD_CONFIG.replace('"French"', '"Japanese"'), 'utf-8');
const wrapper = [
'import os, sys',
`sys.path.insert(0, ${JSON.stringify(dst)})`,
'import render',
'def deny(src, dst):',
" raise OSError(13, 'injected rename failure')",
'os.rename = deny',
'render.main()',
].join('\n');
const res = spawnSync('python3', ['-c', wrapper], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: render publish failed') && res.stdout.includes('injected rename failure'),
`stdout missing the publish-failure HALT with the OS error.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
const preserved = fs.readFileSync(path.join(dir, '_bmad', 'render', 'bmad-dev-auto', 'step-01-clarify-and-route.md'), 'utf-8');
assert(preserved.includes('French'), 'previous render was not preserved after the failed swap');
const leftovers = fs.readdirSync(path.join(dir, '_bmad', 'render')).filter((f) => f !== 'bmad-dev-auto');
assert(leftovers.length === 0, `staging/trash leftovers after failed swap: ${leftovers.join(', ')}`);
});
test('lost deterministic race (equivalent content already published) succeeds', () => {
const { dir, skillDst: dst } = makeProject(GOOD_CONFIG);
const first = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(first.status === 0, `initial render failed: ${first.stdout}\n${first.stderr}`);
fs.writeFileSync(path.join(dir, '_bmad', 'config.toml'), GOOD_CONFIG.replace('"French"', '"Japanese"'), 'utf-8');
// First rename (out_dir -> trash) succeeds; the second is made to lose the
// race: a simulated concurrent winner publishes identical content at
// out_dir before the rename raises.
const wrapper = [
'import os, shutil, sys',
`sys.path.insert(0, ${JSON.stringify(dst)})`,
'import render',
'real_rename = os.rename',
'calls = []',
'def racy(src, dst):',
' calls.append(1)',
' if len(calls) == 1:',
' real_rename(src, dst)',
' return',
' shutil.copytree(src, dst)',
" raise OSError(16, 'injected race loss')",
'os.rename = racy',
'render.main()',
].join('\n');
const res = spawnSync('python3', ['-c', wrapper], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 0, `expected exit 0, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(res.stdout.includes('read and follow'), `stdout missing the dispatch line.\nstdout: ${res.stdout}`);
const published = fs.readFileSync(path.join(dir, '_bmad', 'render', 'bmad-dev-auto', 'step-01-clarify-and-route.md'), 'utf-8');
assert(published.includes('Japanese'), 'published render does not hold the new content after the lost race');
const leftovers = fs.readdirSync(path.join(dir, '_bmad', 'render')).filter((f) => f !== 'bmad-dev-auto');
assert(leftovers.length === 0, `staging/trash leftovers after lost race: ${leftovers.join(', ')}`);
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
for (const dir of extraTmpDirs) {
fs.rmSync(dir, { 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);
+90 -2
View File
@@ -182,9 +182,29 @@ try {
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');
test('workflow.md exists in render output and stdout dispatches to it', () => {
const rendered = path.join(renderDir, 'workflow.md');
assert(fs.existsSync(rendered), `workflow.md not found at ${rendered}`);
// realpathSync: on macOS os.tmpdir() is a symlink (/var -> /private/var)
// while the renderer bakes the physical path it gets from getcwd().
assert(
result.stdout.trim() === `read and follow ${fs.realpathSync(rendered)}`,
`stdout is not exactly the dispatch line to the rendered workflow.md.\nstdout: ${result.stdout}`,
);
});
test('identical re-render is a no-op that leaves the publish untouched', () => {
const workflowPath = fs.realpathSync(path.join(renderDir, 'workflow.md'));
const inodeBefore = fs.statSync(workflowPath).ino;
const contentBefore = fs.readFileSync(workflowPath, 'utf-8');
const rerun = spawnSync('python3', [path.join(skillDst, 'render.py')], {
cwd: skillDst,
encoding: 'utf-8',
});
assert(rerun.status === 0, `re-render exit code ${rerun.status}\nstdout: ${rerun.stdout}\nstderr: ${rerun.stderr}`);
assert(rerun.stdout.trim() === `read and follow ${workflowPath}`, `re-render dispatch line wrong.\nstdout: ${rerun.stdout}`);
assert(fs.readFileSync(workflowPath, 'utf-8') === contentBefore, 're-rendered workflow.md content changed on identical inputs');
assert(fs.statSync(workflowPath).ino === inodeBefore, 'identical re-render republished (new inode) instead of no-op skipping the swap');
});
test('custom override wins — communication_language baked into step files', () => {
@@ -303,6 +323,30 @@ try {
assert(leaks.length === 0, `{workflow.*} leaked in: ${leaks.join(', ')}`);
});
test('sibling cross-references are absolute paths into the render dir', () => {
const physicalRenderDir = fs.realpathSync(renderDir);
assert(
readRendered('workflow.md').includes(`${physicalRenderDir}/step-01-clarify-and-route.md`),
'workflow.md dispatch to step-01 was not rewritten to an absolute render-dir path',
);
assert(
readRendered('step-02-plan.md').includes(`${physicalRenderDir}/spec-template.md`),
'step-02 reference to spec-template.md was not rewritten to an absolute render-dir path',
);
});
test('no relative ./*.md reference survives in any rendered file', () => {
const leaks = renderedMdFiles().filter((f) => /\.\/[\w-]+\.md/.test(readRendered(f)));
assert(leaks.length === 0, `relative ./*.md references leaked in: ${leaks.join(', ')}`);
});
test('SKILL.md is not rendered into the output dir', () => {
assert(
!fs.existsSync(path.join(renderDir, 'SKILL.md')),
'SKILL.md leaked into the render output — the dispatch stub must never be rendered',
);
});
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(', ')}`);
@@ -373,6 +417,50 @@ try {
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('unknown {workflow.*} reference HALTs cleanly (no traceback)', () => {
const { skillDst: dst } = makeProject(
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
);
fs.appendFileSync(path.join(dst, 'workflow.md'), '\n{workflow.no_such_key}\n', 'utf-8');
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: [workflow] customization is missing') &&
res.stdout.includes('`workflow.no_such_key`'),
`stdout missing the unknown-workflow-key HALT directive.\nstdout: ${res.stdout}`,
);
assert(res.stdout.includes('workflow.md'), `HALT directive does not name the referencing file.\nstdout: ${res.stdout}`);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('non-array review_layers override HALTs cleanly (no traceback)', () => {
const { dir, skillDst: dst } = makeProject(
[
'[core]',
'communication_language = "French"',
'document_output_language = "Klingon"',
'planning_artifacts = "{project-root}/plan"',
'implementation_artifacts = "{project-root}/impl"',
].join('\n'),
);
fs.mkdirSync(path.join(dir, '_bmad', 'custom'), { recursive: true });
fs.writeFileSync(path.join(dir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'), '[workflow]\nreview_layers = "not a list"', 'utf-8');
const res = spawnSync('python3', [path.join(dst, 'render.py')], { cwd: dst, encoding: 'utf-8' });
assert(res.status === 1, `expected exit 1, got ${res.status}\nstdout: ${res.stdout}\nstderr: ${res.stderr}`);
assert(
res.stdout.includes('HALT and report to the user: [workflow] customization key `review_layers`') &&
res.stdout.includes('must be an array'),
`stdout missing the non-array review_layers HALT directive.\nstdout: ${res.stdout}`,
);
assert(!res.stderr.includes('Traceback'), `renderer crashed with a traceback instead of HALTing:\n${res.stderr}`);
});
test('non-table [modules] does not crash the renderer', () => {
const { dir, skillDst: dst } = makeProject(
[
+1 -1
View File
@@ -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/bmad-quick-dev/'];
const INSTALL_ONLY_PATHS = ['_config/', 'custom/', 'render/bmad-quick-dev/', 'render/bmad-dev-auto/'];
// Files that are generated at install time and don't exist in the source tree
const INSTALL_GENERATED_FILES = ['config.yaml', 'config.user.yaml'];