diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py index fbb034262..157f97788 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/render.py +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/render.py @@ -2,16 +2,21 @@ """render.py — bmad-quick-dev template renderer. Resolves compile-time {{.variable}} placeholders from BMad's central config, -bakes absolute paths for {project-root} into derived values, and writes -rendered .md files to {project-root}/_bmad/render/bmad-quick-dev/. +bakes absolute paths for {project-root} into derived values, resolves and +inlines the skill's [workflow] customization block, and writes rendered .md +files to {project-root}/_bmad/render/bmad-quick-dev/. Config: four-layer merge of _bmad/config.toml + config.user.toml + custom/config.toml + custom/config.user.toml (post-#2285 installs). Keys surface from [core] and [modules.bmm]. Missing or unparseable config.toml → HALT. -Runtime {variable} placeholders (single curly) pass through untouched for -the LLM to resolve during workflow execution. +Customization: three-layer merge of {skill}/customize.toml + +_bmad/custom/bmad-quick-dev.toml + .user.toml (same structural rules as +resolve_customization.py). The resolved [workflow] values fill {workflow.*} +placeholders, so this skill needs no runtime resolve_customization.py call. +Other single-curly placeholders ({project-root}, {spec_file}, {skill-root}, +...) pass through untouched for the LLM to resolve during workflow execution. Every invocation rebuilds from scratch — no hash, no cache. Python 3.11+ stdlib only. UTF-8 I/O. @@ -86,6 +91,83 @@ def _deep_merge(base, override): return override +def _detect_keyed_merge_field(items): + """Return 'code' or 'id' if every table item carries that same field. + Mixed or partial arrays return None and fall through to append.""" + if not items or not all(isinstance(item, dict) for item in items): + return None + for candidate in ("code", "id"): + if all(item.get(candidate) is not None for item in items): + return candidate + return None + + +def _merge_by_key(base, override, key_name): + result = [] + index_by_key = {} + for item in base: + if not isinstance(item, dict): + continue + if item.get(key_name) is not None: + index_by_key[item[key_name]] = len(result) + result.append(dict(item)) + for item in override: + if not isinstance(item, dict): + result.append(item) + continue + key = item.get(key_name) + if key is not None and key in index_by_key: + result[index_by_key[key]] = dict(item) + else: + if key is not None: + index_by_key[key] = len(result) + result.append(dict(item)) + return result + + +def _merge_arrays(base, override): + """Shape-aware array merge: keyed merge if every item has code/id, else append.""" + base_arr = base if isinstance(base, list) else [] + override_arr = override if isinstance(override, list) else [] + keyed_field = _detect_keyed_merge_field(base_arr + override_arr) + if keyed_field: + return _merge_by_key(base_arr, override_arr, keyed_field) + return base_arr + override_arr + + +def _structural_merge(base, override): + """Faithful port of resolve_customization.py's deep_merge: tables deep-merge, + arrays-of-tables keyed by code/id replace-then-append (other arrays append), + scalars override. Used only for the [workflow] customization layers — the + central-config path keeps its own simpler _deep_merge. Duplicated rather than + imported to keep this skill self-contained.""" + if isinstance(base, dict) and isinstance(override, dict): + result = dict(base) + for key, over_val in override.items(): + result[key] = ( + _structural_merge(result[key], over_val) if key in result else over_val + ) + return result + if isinstance(base, list) and isinstance(override, list): + return _merge_arrays(base, override) + return override + + +def resolve_workflow(root, skill_dir, skill_name): + """Resolve the [workflow] customization block via the three-layer merge + (skill defaults -> team -> user), highest priority last. Same structural + rules as resolve_customization.py. All three layers are optional: a missing + or unparseable file warns (via load_toml) and is skipped.""" + defaults = load_toml(posixpath.join(skill_dir, "customize.toml")) + custom_dir = posixpath.join(root, "_bmad", "custom") + team = load_toml(posixpath.join(custom_dir, f"{skill_name}.toml")) + user = load_toml(posixpath.join(custom_dir, f"{skill_name}.user.toml")) + merged = _structural_merge(defaults, team) + merged = _structural_merge(merged, user) + workflow = merged.get("workflow") + return workflow if isinstance(workflow, dict) else {} + + def load_central_config(root): """Four-layer merge of _bmad/config.toml and its peers (highest priority last). HALTs if the base _bmad/config.toml is missing or unparseable.""" @@ -123,6 +205,41 @@ def render_template(content, vars_): return re.sub(r"\{\{\.(\w+)\}\}", lambda m: vars_.get(m.group(1), ""), content) +def _scalar_str(value): + """Stringify a scalar for inline rendering: booleans lowercase (matching + BMad config conventions), None as empty, everything else via str().""" + if value is None: + return "" + if isinstance(value, bool): + return "true" if value else "false" + return str(value) + + +def _render_workflow_value(value): + """Format a resolved [workflow] value for inline substitution. Lists render + as markdown bullets (empty -> '_None._'); scalars render verbatim. Each list + item uses the same scalar formatting so booleans stay consistent. Entries are + emitted as-is so runtime placeholders like {project-root} survive for the LLM + to resolve.""" + if isinstance(value, list): + if not value: + return "_None._" + return "\n".join(f"- {_scalar_str(item)}" for item in value) + return _scalar_str(value) + + +def render_workflow(content, workflow): + """Resolve {workflow.} placeholders from the resolved [workflow] block. + Unknown keys emit an empty string (missingkey=zero, matching render_template). + Distinct regex from render_template so single-curly runtime placeholders + elsewhere are untouched.""" + return re.sub( + r"\{workflow\.(\w+)\}", + lambda m: _render_workflow_value(workflow.get(m.group(1))), + content, + ) + + def main(): script_dir = os.path.dirname(os.path.abspath(__file__)) skill_name = os.path.basename(script_dir) @@ -144,6 +261,8 @@ def main(): vars_["implementation_artifacts"], "deferred-work.md" ) + workflow = resolve_workflow(root, script_dir.replace(os.sep, "/"), skill_name) + out_dir = posixpath.join(root, "_bmad", "render", skill_name) os.makedirs(out_dir, exist_ok=True) @@ -160,7 +279,7 @@ def main(): with open(src, "r", encoding="utf-8", newline="") as fh: content = fh.read() with open(dst, "w", encoding="utf-8", newline="") as fh: - fh.write(render_template(content, vars_)) + fh.write(render_workflow(render_template(content, vars_), workflow)) count += 1 print(f"render.py: rendered {count} files -> {out_dir}", file=sys.stderr) diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md index a3ff75c2e..620b043d7 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-05-present.md @@ -73,6 +73,6 @@ Workflow complete. ## On Complete -Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete` +If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally. -If the resolved `workflow.on_complete` is non-empty, follow it as the final terminal instruction before exiting. +{workflow.on_complete} diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md b/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md index f6cb711a7..291e20bde 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/step-oneshot.md @@ -62,6 +62,6 @@ Workflow complete. ## On Complete -Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow.on_complete` +If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally. -If the resolved `workflow.on_complete` is non-empty, follow it as the final terminal instruction before exiting. +{workflow.on_complete} diff --git a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md index f9e8f806a..632fb5047 100644 --- a/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md +++ b/src/bmm-skills/4-implementation/bmad-quick-dev/workflow.md @@ -35,27 +35,19 @@ A specification should target a **single user-facing goal** within **900–1600 ## On Activation -### Step 1: Resolve the Workflow Block +### Step 1: Execute Prepend Steps -Run: `python3 {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow` +Execute each of these steps in order before proceeding (`_None._` means skip): -**If the script fails**, resolve the `workflow` block yourself by reading these three files in base → team → user order and applying the same structural merge rules as the resolver: +{workflow.activation_steps_prepend} -1. `{skill-root}/customize.toml` — defaults -2. `{project-root}/_bmad/custom/{skill-name}.toml` — team overrides -3. `{project-root}/_bmad/custom/{skill-name}.user.toml` — personal overrides +### Step 2: Load Persistent Facts -Any missing file is skipped. Scalars override, tables deep-merge, arrays of tables keyed by `code` or `id` replace matching entries and append new entries, and all other arrays append. +Treat every entry below as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim (`_None._` means none): -### Step 2: Execute Prepend Steps +{workflow.persistent_facts} -Execute each entry in `{workflow.activation_steps_prepend}` in order before proceeding. - -### Step 3: Load Persistent Facts - -Treat every entry in `{workflow.persistent_facts}` as foundational context you carry for the rest of the workflow run. Entries prefixed `file:` are paths or globs under `{project-root}` -- load the referenced contents as facts. All other entries are facts verbatim. - -### Step 4: Load Config +### Step 3: Load Config Load config from `{{.main_config}}` and resolve: @@ -69,13 +61,15 @@ Load config from `{{.main_config}}` and resolve: - Language MUST be tailored to `{{.user_skill_level}}` - Generate all documents in `{{.document_output_language}}` -### Step 5: Greet the User +### Step 4: Greet the User Greet `{{.user_name}}`, speaking in `{{.communication_language}}`. -### Step 6: Execute Append Steps +### Step 5: Execute Append Steps -Execute each entry in `{workflow.activation_steps_append}` in order. +Execute each of these steps in order (`_None._` means skip): + +{workflow.activation_steps_append} Activation is complete. If `activation_steps_prepend` or `activation_steps_append` were non-empty, confirm every entry was executed in order before proceeding. Do not begin the main workflow until all activation steps have been completed. diff --git a/test/test-quick-dev-renderer.js b/test/test-quick-dev-renderer.js index d65263f2a..22746fc70 100644 --- a/test/test-quick-dev-renderer.js +++ b/test/test-quick-dev-renderer.js @@ -1,10 +1,16 @@ /** * Smoke test for bmad-quick-dev render.py * - * Sets up a temp project with a base _bmad/config.toml and an override - * _bmad/custom/config.user.toml, runs render.py, and asserts: - * 1. The override wins (workflow.md contains "Japanese"). + * Sets up a temp project with base + override config layers and a + * _bmad/custom/bmad-quick-dev.user.toml [workflow] override, runs render.py, + * and asserts: + * 1. The central-config override wins (workflow.md contains "Japanese"). * 2. sprint_status is an absolute path rooted at the temp project dir. + * 3. [workflow] customization is self-resolved and inlined: prepend bullet, + * persistent_facts append (base kept), empty list -> _None._, on_complete + * scalar baked into step-05/step-oneshot. + * 4. No {workflow.*} placeholder or resolve_customization.py call survives + * in any rendered file. * * Usage: node test/test-quick-dev-renderer.js * Exit codes: 0 = all tests pass, 1 = test failures @@ -97,6 +103,21 @@ try { 'utf-8', ); + // _bmad/custom/bmad-quick-dev.user.toml — [workflow] customization override. + // Exercises render.py's self-resolution: array append (persistent_facts), + // list inlining (activation_steps_prepend), and scalar override (on_complete), + // all baked into the rendered output with no runtime resolve_customization.py. + fs.writeFileSync( + path.join(tmpDir, '_bmad', 'custom', 'bmad-quick-dev.user.toml'), + [ + '[workflow]', + 'activation_steps_prepend = ["TEST_PREPEND_STEP"]', + 'persistent_facts = ["TEST_EXTRA_FACT"]', + 'on_complete = "TEST_ON_COMPLETE_INSTRUCTION"', + ].join('\n'), + 'utf-8', + ); + // Copy skill dir into /bmad-quick-dev/ so find_project_root() walks // up and finds /_bmad/, and os.path.basename(script_dir) resolves // to the real skill name so the render output lands at @@ -115,6 +136,10 @@ try { encoding: 'utf-8', }); + const renderDir = path.join(tmpDir, '_bmad', 'render', 'bmad-quick-dev'); + const readRendered = (name) => fs.readFileSync(path.join(renderDir, name), 'utf-8'); + const renderedMdFiles = () => fs.readdirSync(renderDir).filter((f) => f.endsWith('.md')); + // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -147,6 +172,38 @@ try { `workflow.md excerpt (first 2000 chars):\n${content.slice(0, 2000)}`, ); }); + + test('workflow override — prepend step inlined as a bullet', () => { + const content = readRendered('workflow.md'); + assert(content.includes('- TEST_PREPEND_STEP'), 'activation_steps_prepend not inlined as a bullet'); + }); + + test('workflow override — persistent_facts append (base kept, override added)', () => { + const content = readRendered('workflow.md'); + assert(content.includes('- TEST_EXTRA_FACT'), 'override persistent_fact not inlined'); + assert(content.includes('project-context.md'), 'base persistent_fact dropped — append semantics broken'); + }); + + test('empty activation_steps_append renders the _None._ sentinel', () => { + const content = readRendered('workflow.md'); + assert(content.includes('_None._'), '_None._ sentinel missing for empty list'); + }); + + test('on_complete scalar inlined into step-05 and step-oneshot', () => { + for (const file of ['step-05-present.md', 'step-oneshot.md']) { + assert(readRendered(file).includes('TEST_ON_COMPLETE_INSTRUCTION'), `on_complete not inlined into ${file}`); + } + }); + + test('no {workflow.*} placeholder survives in any rendered file', () => { + const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('{workflow.')); + assert(leaks.length === 0, `{workflow.*} leaked in: ${leaks.join(', ')}`); + }); + + test('no resolve_customization.py reference survives in any rendered file', () => { + const leaks = renderedMdFiles().filter((f) => readRendered(f).includes('resolve_customization.py')); + assert(leaks.length === 0, `resolve_customization.py still referenced in: ${leaks.join(', ')}`); + }); } finally { fs.rmSync(tmpDir, { recursive: true, force: true }); }