refactor(quick-dev): resolve [workflow] customization in render.py

render.py now merges the three customize layers (customize.toml ->
custom/bmad-quick-dev.toml -> .user.toml) with the same structural rules as
resolve_customization.py and inlines the resolved [workflow] values, so no
{workflow.*} placeholder survives. workflow.md drops its Step 1 runtime
resolver + manual-merge fallback; step-05 and step-oneshot drop their runtime
workflow.on_complete calls. The shared resolve_customization.py and every
other skill are untouched. Smoke test extended with a [workflow] override
fixture covering inlining, array append, and no-leak assertions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alex Verkhovsky 2026-05-24 23:12:31 -07:00
parent 61531ffaee
commit 7e65f5004c
5 changed files with 200 additions and 30 deletions

View File

@ -2,16 +2,21 @@
"""render.py — bmad-quick-dev template renderer.
Resolves compile-time {{.variable}} placeholders from BMad's central config,
bakes absolute paths for {project-root} into derived values, and writes
rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/.
bakes absolute paths for {project-root} into derived values, resolves and
inlines the skill's [workflow] customization block, and writes rendered .md
files to {project-root}/_bmad/render/bmad-quick-dev/.
Config: four-layer merge of _bmad/config.toml + config.user.toml +
custom/config.toml + custom/config.user.toml (post-#2285 installs).
Keys surface from [core] and [modules.bmm]. Missing or unparseable
config.toml HALT.
Runtime {variable} placeholders (single curly) pass through untouched for
the LLM to resolve during workflow execution.
Customization: three-layer merge of {skill}/customize.toml +
_bmad/custom/bmad-quick-dev.toml + .user.toml (same structural rules as
resolve_customization.py). The resolved [workflow] values fill {workflow.*}
placeholders, so this skill needs no runtime resolve_customization.py call.
Other single-curly placeholders ({project-root}, {spec_file}, {skill-root},
...) pass through untouched for the LLM to resolve during workflow execution.
Every invocation rebuilds from scratch no hash, no cache.
Python 3.11+ stdlib only. UTF-8 I/O.
@ -86,6 +91,83 @@ def _deep_merge(base, override):
return override
def _detect_keyed_merge_field(items):
"""Return 'code' or 'id' if every table item carries that same field.
Mixed or partial arrays return None and fall through to append."""
if not items or not all(isinstance(item, dict) for item in items):
return None
for candidate in ("code", "id"):
if all(item.get(candidate) is not None for item in items):
return candidate
return None
def _merge_by_key(base, override, key_name):
result = []
index_by_key = {}
for item in base:
if not isinstance(item, dict):
continue
if item.get(key_name) is not None:
index_by_key[item[key_name]] = len(result)
result.append(dict(item))
for item in override:
if not isinstance(item, dict):
result.append(item)
continue
key = item.get(key_name)
if key is not None and key in index_by_key:
result[index_by_key[key]] = dict(item)
else:
if key is not None:
index_by_key[key] = len(result)
result.append(dict(item))
return result
def _merge_arrays(base, override):
"""Shape-aware array merge: keyed merge if every item has code/id, else append."""
base_arr = base if isinstance(base, list) else []
override_arr = override if isinstance(override, list) else []
keyed_field = _detect_keyed_merge_field(base_arr + override_arr)
if keyed_field:
return _merge_by_key(base_arr, override_arr, keyed_field)
return base_arr + override_arr
def _structural_merge(base, override):
"""Faithful port of resolve_customization.py's deep_merge: tables deep-merge,
arrays-of-tables keyed by code/id replace-then-append (other arrays append),
scalars override. Used only for the [workflow] customization layers the
central-config path keeps its own simpler _deep_merge. Duplicated rather than
imported to keep this skill self-contained."""
if isinstance(base, dict) and isinstance(override, dict):
result = dict(base)
for key, over_val in override.items():
result[key] = (
_structural_merge(result[key], over_val) if key in result else over_val
)
return result
if isinstance(base, list) and isinstance(override, list):
return _merge_arrays(base, override)
return override
def resolve_workflow(root, skill_dir, skill_name):
"""Resolve the [workflow] customization block via the three-layer merge
(skill defaults -> team -> user), highest priority last. Same structural
rules as resolve_customization.py. All three layers are optional: a missing
or unparseable file warns (via load_toml) and is skipped."""
defaults = load_toml(posixpath.join(skill_dir, "customize.toml"))
custom_dir = posixpath.join(root, "_bmad", "custom")
team = load_toml(posixpath.join(custom_dir, f"{skill_name}.toml"))
user = load_toml(posixpath.join(custom_dir, f"{skill_name}.user.toml"))
merged = _structural_merge(defaults, team)
merged = _structural_merge(merged, user)
workflow = merged.get("workflow")
return workflow if isinstance(workflow, dict) else {}
def load_central_config(root):
"""Four-layer merge of _bmad/config.toml and its peers (highest priority
last). HALTs if the base _bmad/config.toml is missing or unparseable."""
@ -123,6 +205,41 @@ def render_template(content, vars_):
return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content)
def _scalar_str(value):
"""Stringify a scalar for inline rendering: booleans lowercase (matching
BMad config conventions), None as empty, everything else via str()."""
if value is None:
return ""
if isinstance(value, bool):
return "true" if value else "false"
return str(value)
def _render_workflow_value(value):
"""Format a resolved [workflow] value for inline substitution. Lists render
as markdown bullets (empty -> '_None._'); scalars render verbatim. Each list
item uses the same scalar formatting so booleans stay consistent. Entries are
emitted as-is so runtime placeholders like {project-root} survive for the LLM
to resolve."""
if isinstance(value, list):
if not value:
return "_None._"
return "\n".join(f"- {_scalar_str(item)}" for item in value)
return _scalar_str(value)
def render_workflow(content, workflow):
"""Resolve {workflow.<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."""
return re.sub(
r"\{workflow\.(\w+)\}",
lambda m: _render_workflow_value(workflow.get(m.group(1))),
content,
)
def main():
script_dir = os.path.dirname(os.path.abspath(__file__))
skill_name = os.path.basename(script_dir)
@ -144,6 +261,8 @@ def main():
vars_["implementation_artifacts"], "deferred-work.md"
)
workflow = resolve_workflow(root, script_dir.replace(os.sep, "/"), skill_name)
out_dir = posixpath.join(root, "_bmad", "render", skill_name)
os.makedirs(out_dir, exist_ok=True)
@ -160,7 +279,7 @@ def main():
with open(src, "r", encoding="utf-8", newline="") as fh:
content = fh.read()
with open(dst, "w", encoding="utf-8", newline="") as fh:
fh.write(render_template(content, vars_))
fh.write(render_workflow(render_template(content, vars_), workflow))
count += 1
print(f"render.py: rendered {count} files -> {out_dir}", file=sys.stderr)

View File

@ -73,6 +73,6 @@ Workflow complete.
## On Complete
Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete`
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
If the resolved `workflow.on_complete` is non-empty, follow it as the final terminal instruction before exiting.
{workflow.on_complete}

View File

@ -62,6 +62,6 @@ Workflow complete.
## On Complete
Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete`
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
If the resolved `workflow.on_complete` is non-empty, follow it as the final terminal instruction before exiting.
{workflow.on_complete}

View File

@ -35,27 +35,19 @@ A specification should target a **single user-facing goal** within **9001600
## On Activation
### Step 1: Resolve the Workflow Block
### Step 1: Execute Prepend Steps
Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`
Execute each of these steps in order before proceeding (`_None._` means skip):
**If the script fails**, resolve the `workflow` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver:
{workflow.activation_steps_prepend}
1. `{skill-root}/customize.toml` — defaults
2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides
3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides
### Step 2: Load Persistent Facts
Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append.
Treat every entry below as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim (`_None._` means none):
### Step 2: Execute Prepend Steps
{workflow.persistent_facts}
Execute each entry in `{workflow.activation_steps_prepend}` in order before proceeding.
### Step 3: Load Persistent Facts
Treat every entry in `{workflow.persistent_facts}` as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim.
### Step 4: Load Config
### Step 3: Load Config
Load config from `{{.main_config}}` and resolve:
@ -69,13 +61,15 @@ Load config from `{{.main_config}}` and resolve:
- Language MUST be tailored to `{{.user_skill_level}}`
- Generate all documents in `{{.document_output_language}}`
### Step 5: Greet the User
### Step 4: Greet the User
Greet `{{.user_name}}`, speaking in `{{.communication_language}}`.
### Step 6: Execute Append Steps
### Step 5: Execute Append Steps
Execute each entry in `{workflow.activation_steps_append}` in order.
Execute each of these steps in order (`_None._` means skip):
{workflow.activation_steps_append}
Activation is complete. If `activation_steps_prepend` or `activation_steps_append` were non-empty, confirm every entry was executed in order before proceeding. Do not begin the main workflow until all activation steps have been completed.

View File

@ -1,10 +1,16 @@
/**
* Smoke test for bmad-quick-dev render.py
*
* Sets up a temp project with a base _bmad/config.toml and an override
* _bmad/custom/config.user.toml, runs render.py, and asserts:
* 1. The override wins (workflow.md contains "Japanese").
* Sets up a temp project with base + override config layers and a
* _bmad/custom/bmad-quick-dev.user.toml [workflow] override, runs render.py,
* and asserts:
* 1. The central-config override wins (workflow.md contains "Japanese").
* 2. sprint_status is an absolute path rooted at the temp project dir.
* 3. [workflow] customization is self-resolved and inlined: prepend bullet,
* persistent_facts append (base kept), empty list -> _None._, on_complete
* scalar baked into step-05/step-oneshot.
* 4. No {workflow.*} placeholder or resolve_customization.py call survives
* in any rendered file.
*
* Usage: node test/test-quick-dev-renderer.js
* Exit codes: 0 = all tests pass, 1 = test failures
@ -97,6 +103,21 @@ try {
'utf-8',
);
// _bmad/custom/bmad-quick-dev.user.toml — [workflow] customization override.
// Exercises render.py's self-resolution: array append (persistent_facts),
// list inlining (activation_steps_prepend), and scalar override (on_complete),
// all baked into the rendered output with no runtime resolve_customization.py.
fs.writeFileSync(
path.join(tmpDir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'),
[
'[workflow]',
'activation_steps_prepend = ["TEST_PREPEND_STEP"]',
'persistent_facts = ["TEST_EXTRA_FACT"]',
'on_complete = "TEST_ON_COMPLETE_INSTRUCTION"',
].join('\n'),
'utf-8',
);
// Copy skill dir into <tmpDir>/bmad-quick-dev/ 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
@ -115,6 +136,10 @@ try {
encoding: 'utf-8',
});
const renderDir = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev');
const readRendered = (name) => fs.readFileSync(path.join(renderDir, name), 'utf-8');
const renderedMdFiles = () => fs.readdirSync(renderDir).filter((f) => f.endsWith('.md'));
// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------
@ -147,6 +172,38 @@ try {
`workflow.md excerpt (first 2000 chars):\n${content.slice(0, 2000)}`,
);
});
test('workflow override — prepend step inlined as a bullet', () => {
const content = readRendered('workflow.md');
assert(content.includes('- TEST_PREPEND_STEP'), 'activation_steps_prepend not inlined as a bullet');
});
test('workflow override — persistent_facts append (base kept, override added)', () => {
const content = readRendered('workflow.md');
assert(content.includes('- TEST_EXTRA_FACT'), 'override persistent_fact not inlined');
assert(content.includes('project-context.md'), 'base persistent_fact dropped — append semantics broken');
});
test('empty activation_steps_append renders the _None._ sentinel', () => {
const content = readRendered('workflow.md');
assert(content.includes('_None._'), '_None._ sentinel missing for empty list');
});
test('on_complete scalar inlined into step-05 and step-oneshot', () => {
for (const file of ['step-05-present.md', 'step-oneshot.md']) {
assert(readRendered(file).includes('TEST_ON_COMPLETE_INSTRUCTION'), `on_complete not inlined into ${file}`);
}
});
test('no {workflow.*} placeholder survives in any rendered file', () => {
const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('{workflow.'));
assert(leaks.length === 0, `{workflow.*} leaked in: ${leaks.join(', ')}`);
});
test('no resolve_customization.py reference survives in any rendered file', () => {
const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('resolve_customization.py'));
assert(leaks.length === 0, `resolve_customization.py still referenced in: ${leaks.join(', ')}`);
});
} finally {
fs.rmSync(tmpDir, { recursive: true, force: true });
}