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