Skip to content

feat(quick-dev): render templates via stdlib Python at skill entry#2281

Draft
alexeyv wants to merge 13 commits into
mainfrom
feat/quick-dev-python-config
Draft

feat(quick-dev): render templates via stdlib Python at skill entry#2281
alexeyv wants to merge 13 commits into
mainfrom
feat/quick-dev-python-config

Conversation

@alexeyv

@alexeyv alexeyv commented Apr 18, 2026

Copy link
Copy Markdown
Collaborator

What

Move compile-time variable substitution out of the LLM and into a deterministic Python step.

SKILL.md becomes a two-line stdout-dispatch shim that runs render.py and follows the instruction it prints. The renderer walks up to find _bmad/, loads flat-YAML config, applies smart defaults, bakes {project-root} to an absolute path, and writes rendered .md files to _bmad/render/bmad-quick-dev/.

Compile-time refs ({{.var}}) get substituted at render time. LLM-runtime refs ({var}) pass through untouched.

Why

Today the LLM resolves {project-root}, {main_config}, {implementation_artifacts}, {communication_language}, {sprint_status}, and {deferred_work_file} at runtime by reading config. That pushes substitution onto the LLM (fragile: it can misresolve or drift), leaves no place for project-level overrides beyond core BMM config, and conflates compile-time refs with LLM-runtime refs.

Moving compile-time substitution into a deterministic Python step eliminates LLM drift and opens the door for other skills to adopt the same pattern later.

How

Renderer (render.py)

  • Python 3 stdlib only. UTF-8 I/O. Every invocation rebuilds from scratch — no hash, no cache.
  • find_project_root walks up from cwd; HALT to stdout if no _bmad/ is found.
  • Flat-YAML parser skips inline dicts/lists, indented children, and comments. Stderr on missing config; smart defaults applied.
  • {{.var}} substitution; unresolved refs emit empty string (Go missingkey=zero semantics).
  • Smart defaults for planning_artifacts / implementation_artifacts / communication_language. Derives sprint_status / deferred_work_file from implementation_artifacts.
  • Renders every .md in the skill dir except SKILL.md.
  • On success, stderr summary plus a single stdout line: read and follow {workflow_md}. On failure, stdout HALT directive.

Skill entry (SKILL.md)

  • Two-line shim: python render.py, then follow stdout. No template tokens in SKILL.md itself — per the Anthropic skills spec, script stdout is the defined agent-communication channel.

Template conversions

  • workflow.md, step-01..05, step-oneshot, sync-sprint-status: convert every compile-time {var} reference to {{.var}}. Runtime refs preserved.
  • spec-template.md untouched (single-curly comment hint stays as documentation; protected by the new TPL-01 validator rule).

Skill-prose cleanups bundled in

  • Remove dead step-file frontmatter: empty-string variable declarations (spec_file, story_key, diff_output, review_mode) in quick-dev step-01 and code-review step-01; empty ---/--- blocks in step-03/step-05. The specLoopIteration counter init was moved from step-04 frontmatter into the step body, where first-entry vs loopback semantics are explicit.
  • Unify the language rule across all six quick-dev step files plus workflow.md to the canonical two-line form used by bmad-checkpoint-preview / bmad-create-story / bmad-retrospective: Speak in X. Write any file output in Y. Closes the gap where document_output_language was loaded but never enforced on file writes.
  • Trim workflow.md to its load-bearing parts: drop INITIALIZATION SEQUENCE (only {date} was actually consumed downstream, and it is system-generated rather than config-sourced); drop redundant HALT meta-rules (every step file already has explicit HALT directives at every checkpoint); merge Step Processing Rules with Critical Rules into one list; drop the self-descriptive WORKFLOW ARCHITECTURE intro bullets (Micro-file Design, Just-In-Time Loading, etc.) that did not change LLM behavior. Net: 73 lines down to 38.
  • Remove the now-dead main_config derivation from render.py — nothing in quick-dev still references {{.main_config}} after the workflow.md trim.

Tooling

  • tools/validate-skills.js — add TPL-01 rule. Files whose name contains template must not contain compile-time {{.var}} substitutions. Template files seed durable, version-controlled artifacts that execute on other machines; baking a value at render time would freeze a machine-local path into every downstream artifact. HIGH severity.
  • tools/validate-file-refs.js — add render/ to INSTALL_ONLY_PATHS so the validator recognizes the runtime-generated buffer.
  • tools/skill-validator.md — document TPL-01; deterministic rule count bumped from 14 to 15.

Testing

  • npm ci && npm run quality passes — full suite runs via pre-commit hook (lint, markdownlint, test:refs, test:install, format:check, validate:skills). All 242 installation tests pass. Skills validator strict-mode pass — only 2 LOW findings, both in skills not touched by this PR.
  • Three adversarial review passes ran in parallel (blind hunter, edge-case hunter, acceptance auditor) at session-capable model tier on the original render.py + template-conversion change. Four patches applied in-branch: stderr on missing config, fix for silent drop of unquoted {...} values, explicit UTF-8 encoding, removal of {{if}}...{{end}} support (nested-case bug; not used by any current template). 10 findings deferred to harness-side deferred-work.md.
  • Skill-prose cleanups (frontmatter, language rule, workflow.md trim) reviewed interactively; no semantic change to runtime behavior — every load-bearing reference traced and confirmed before deletion.
  • Manual: python3 src/bmm-skills/4-implementation/bmad-quick-dev/render.py from a harness cwd produces expected rendered output; all {{.var}} placeholders resolved to absolute paths; runtime {var} refs preserved; spec-template.md's {project-root}/-prefix comment passes through as documentation hint.

Follow-ups deferred

  • Migrate _bmad/<module>/config.yaml to TOML. The hand-rolled flat-YAML parser in render.py is the cleanest stdlib-Python option today, but it relies on the undocumented invariant that installed configs are flat scalar maps. TOML (tomllib, stdlib 3.11+) is a strict superset of the data model used today and removes the parser entirely. Blast radius and migration plan documented in harness deferred-work.md.
  • Six other render.py findings (regex hyphen handling, error broadening, atomic writes, etc.) — see harness deferred-work.md.

@alexeyv alexeyv force-pushed the feat/quick-dev-python-config branch 2 times, most recently from 752c086 to 94cf189 Compare April 22, 2026 07:41
@bmadcode bmadcode closed this Apr 26, 2026
@bmadcode bmadcode reopened this Apr 26, 2026
@alexeyv alexeyv force-pushed the feat/quick-dev-python-config branch from 94cf189 to 550807f Compare May 4, 2026 16:17
@alexeyv alexeyv force-pushed the feat/quick-dev-python-config branch 3 times, most recently from 3d07364 to c1c6db9 Compare May 25, 2026 06:49
alexeyv and others added 13 commits June 11, 2026 02:51
Move compile-time variable substitution out of the LLM and into a
deterministic Python step. SKILL.md becomes a two-line stdout-dispatch
shim that runs render.py and follows the instruction it prints. The
renderer reads BMad configuration from the central four-layer TOML
surface introduced in #2285 (_bmad/config.toml plus config.user.toml
and the two _bmad/custom/ overrides), with a fallback to the legacy
per-module _bmad/bmm/config.yaml for pre-#2285 installs.

Compile-time refs ({{.var}}) get substituted at render time. LLM-runtime
refs ({var}) pass through untouched.

Renderer (render.py)
- Python 3 stdlib only (tomllib, already bundled since 3.11). UTF-8 I/O.
  Every invocation rebuilds from scratch — no hash, no cache.
- find_project_root walks up from cwd; HALT to stdout if no _bmad/
  is found anywhere on the path.
- load_central_config deep-merges the four TOML layers in priority
  order (base-team → base-user → custom-team → custom-user) so user
  overrides in _bmad/custom/config.user.toml win over installer-
  regenerated base values. flatten_central_config lifts scalar keys
  from [core] and [modules.bmm] into the renderer's flat namespace;
  module keys beat core on collision (matches the installer's own
  core-key-stripping behavior).
- When _bmad/config.toml is absent, falls through to the legacy
  flat-YAML parser for _bmad/bmm/config.yaml — the renderer keeps
  working across the #2285 transition.
- {{.var}} substitution; unresolved refs emit empty string (Go
  missingkey=zero semantics).
- Smart defaults for planning_artifacts / implementation_artifacts /
  communication_language applied after config load. Derives
  sprint_status / deferred_work_file from implementation_artifacts.
  {{.main_config}} points at whichever surface was actually read.
- Renders every .md in the skill dir except SKILL.md to
  {project-root}/_bmad/render/bmad-quick-dev/.
- On success, stderr summary plus a single stdout line:
  "read and follow {workflow_md}". On failure, stdout HALT directive —
  per the Anthropic skills spec, script stdout is the defined agent-
  communication channel.

Skill entry (SKILL.md)
- Two-line shim: run python render.py, follow stdout. No template
  tokens in SKILL.md itself.

Template conversions
- workflow.md, step-01..05, step-oneshot, sync-sprint-status: convert
  every compile-time {var} reference to {{.var}}. Runtime refs
  preserved.
- spec-template.md untouched (single-curly comment hint stays as
  documentation).

Skill-prose cleanups bundled in
- Remove dead step-file frontmatter: empty-string variable declarations
  (spec_file, story_key, diff_output, review_mode) in quick-dev step-01
  and code-review step-01; empty --- --- blocks in step-03 and step-05;
  the specLoopIteration counter init moved from step-04 frontmatter into
  the step body where first-entry vs loopback semantics are explicit.
- Unify the language rule across all six quick-dev step files plus
  workflow.md.

Tooling
- tools/validate-skills.js: add TPL-01 rule. Files whose name contains
  "template" must not contain compile-time {{.var}} substitutions.
  Template files seed durable, version-controlled artifacts that
  execute on other machines; baking a value at render time would
  freeze a machine-local path into every downstream artifact.
- tools/validate-file-refs.js: add render/ to INSTALL_ONLY_PATHS so
  the validator recognizes the runtime-generated buffer.
- tools/skill-validator.md: document TPL-01; deterministic rule count
  bumped from 14 to 15.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Single happy path: central _bmad/config.toml with four-layer merge,
Python 3.11+ required (no ImportError guard), HALT if config missing.
Deletes load_flat_yaml, the YAML fallback branch, the setdefault block
for planning_artifacts/implementation_artifacts/communication_language,
and the tomllib ImportError fallback.

Part of plan-quick-dev-python-config-hardening.md (F0).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
On Windows, os.path.join returns backslash-separated paths that can
misrender as escape sequences when later concatenated into POSIX
shell strings or regexes. Normalize the project root to forward
slashes after find_project_root, and use posixpath.join for every
path that gets baked into rendered .md files or joined into config
values. os.makedirs and os.listdir accept forward-slash paths on
Windows, so their call sites stay as-is.

Part of plan-quick-dev-python-config-hardening.md (F3).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Python text-mode open() with the platform default performs universal-
newline translation: on Windows, LF source files get written as CRLF,
producing spurious diffs when rendered output is compared against
source. Pass newline="" on both the source read and the rendered
write so line endings pass through verbatim.

Part of plan-quick-dev-python-config-hardening.md (F4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
render.py rebuilds from scratch per the docstring, but
makedirs(exist_ok=True) only overwrites files that still exist in
the source — stale outputs from renamed/deleted source files linger
in _bmad/render/bmad-quick-dev/ forever. Remove every .md in the
render dir before the render loop; keep the dir itself and any
non-.md files.

Part of plan-quick-dev-python-config-hardening.md (F5).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous INSTALL_ONLY_PATHS entry 'render/' was a blanket prefix
that let every {project-root}/_bmad/render/... reference in any skill
slip past validation. Narrow to 'render/bmad-quick-dev/' so only this
skill's render buffer is whitelisted. Future skills adopting the
stdout-dispatch renderer pattern add their own entries explicitly.

Part of plan-quick-dev-python-config-hardening.md (F6).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
New test/test-quick-dev-renderer.js spins up a temp project with
base _bmad/config.toml and a _bmad/custom/config.user.toml override,
runs render.py, and asserts the override wins in rendered workflow.md
and that sprint_status is rooted at an absolute path in the temp
project. Registered as test:renderer in package.json and chained
into the npm test script.

Part of plan-quick-dev-python-config-hardening.md (F7).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Load the four config layers through a load_toml helper that marks the
base _bmad/config.toml as required. A missing, unparseable, or unreadable
base now prints a HALT directive to stdout and exits, instead of being
silently skipped and then crashing downstream with a KeyError when a
derived value (e.g. implementation_artifacts) is absent. Optional layers
still warn on stderr and fall back to empty. Merge semantics are
unchanged (dict-aware deep merge, override wins for lists and scalars).
The bare `python render.py` shim assumes the agent's working directory is
the skill directory, but agents run from the project root, so the script
is not found. Reference it as `{skill-root}/render.py` — BMAD's standard
token for a skill's installed directory, already used by every other
skill's resolve_customization.py invocation — and add the one-line
`{skill-root}` explainer so the model resolves it from an instruction
rather than guessing. Interpreter stays `python`; the python vs python3
choice is a separate cross-platform concern.
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>
The shim called bare `python`, which can resolve to Python 2 or be absent;
render.py needs 3.11+ for tomllib. Spell out python3 and the version
requirement. Also make the exit code authoritative: on a non-zero exit
(including an uncaught crash that writes only to stderr), do not proceed --
report what was printed and stop.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The "rendered N files" progress line was pure diagnostic noise. The shim
already tells the LLM to ignore stderr and follow the stdout instruction, so
on success render.py now prints only the "read and follow ..." line.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…orkflow

The gate ported from #2398 defended against runtime customization
indirection: agents guessed resolver outputs instead of executing them,
silently skipping append steps. render.py inlines the prepend/append
entries into the rendered workflow.md, so there is nothing left to
short-circuit, and each inlined list already carries its own execute-
in-order imperative. In the default install both lists render as
_None._ and the gate is pure noise.
@alexeyv alexeyv force-pushed the feat/quick-dev-python-config branch from 0c08cdd to 93ff8d4 Compare June 11, 2026 15:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants