diff --git a/skills/bmad-build-auto/step-01-clarify-and-route.md b/skills/bmad-build-auto/step-01-clarify-and-route.md index 38ac4e7440..52e80867aa 100644 --- a/skills/bmad-build-auto/step-01-clarify-and-route.md +++ b/skills/bmad-build-auto/step-01-clarify-and-route.md @@ -42,7 +42,11 @@ This runs on the output of `tickets.py find` for one ticket. Set `ticket_args` t 1. Load context. - **A ticket from the tree** — when **Ticket resolution** set `plan_file`: the entry, its epic file and what that file's References name, and the story file when there is one are already the intent. For continuity, read the plans beside `plan_file` whose `ticket` is one of find's `after` ids that is a plain number (an entry of the same epic; a ref such as `1.5` is another epic's). Carry forward each one's **Code Map**, **Design Notes**, **Implementation Notes**, **Plan Change Log**, and **Tasks & Acceptance**, where present, as continuity context for step-02. - **Anything else:** - - List `{{ config.output_folder }}/{active_initiative}/`, then `{{ config.output_folder }}/`. + {% if initiative_folder == config.output_folder %} + - List `{{ config.output_folder }}/`. + {% else %} + - List `{{ initiative_folder }}/`, then `{{ config.output_folder }}/`. + {% endif %} - If the invocation prompt points to an unformatted plan or intent file, ingest that file. Do not scan for unrelated intent files. - Planning documents sit in folders by type, main file named after the folder. Typical ones: - **PRD** (`prd-*/prd-*.md`) — product requirements and success criteria @@ -56,7 +60,7 @@ This runs on the output of `tickets.py find` for one ticket. Set `ticket_args` t 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 `{plan_file}` frontmatter `warnings`. Do not split or block. 5. Set the plan file. - 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 `{{ config.output_folder }}/{active_initiative}/plan-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `plan_file` to that path, **EARLY EXIT** → `{{ rendered("step-02-plan.md") }}`); otherwise append `-2`, `-3`, etc. Set `plan_file` = `{{ config.output_folder }}/{active_initiative}/plan-{slug}.md`. + 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 `{{ initiative_folder }}/plan-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `plan_file` to that path, **EARLY EXIT** → `{{ rendered("step-02-plan.md") }}`); otherwise append `-2`, `-3`, etc. Set `plan_file` = `{{ initiative_folder }}/plan-{slug}.md`. ## NEXT diff --git a/skills/bmad-build-auto/workflow.md b/skills/bmad-build-auto/workflow.md index ec30c8b246..1f065a42de 100644 --- a/skills/bmad-build-auto/workflow.md +++ b/skills/bmad-build-auto/workflow.md @@ -13,7 +13,7 @@ To HALT with a final status and optional blocking condition. The halts `blocked 1. **A ticket from the tree** (`{ticket_args}` is set) with final status `blocked`: run `uv run {project-root}/_bmad/method/scripts/tickets.py --project-root {project-root} mark {ticket_args} blocked --blocked `, with each argument quoted for the shell, which writes `status`, `blocked_at`, and `blocked_reason` to `{plan_file}` and creates it when there is none. Then append missing result details under `## Auto Run Result` in `{plan_file}`. If `mark` fails, follow 2 instead. 2. **Otherwise:** - If `{plan_file}` is known and exists, update `status` in frontmatter and append missing result details under `## Auto Run Result`. - - If `{plan_file}` is unknown or missing, create `{{ config.output_folder }}/{active_initiative}/bmad-build-auto-result-.md` with: + - If `{plan_file}` is unknown or missing, create `{{ initiative_folder }}/bmad-build-auto-result-.md` with: ```markdown --- status: @@ -56,7 +56,6 @@ A full plan is "Ready for Development" when: - Every operational cross-file reference in this workflow is an absolute snapshot path. Open it directly; do not resolve it relative to a skill directory. - `{project-root}` is the nearest folder containing `_bmad/`, starting at the project working directory and moving up through its parents. -- `{active_initiative}` is the value printed by `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key core.active_initiative`, read once before step 1. When it is unset, drop `/{active_initiative}` from every path. - Whenever this workflow captures or records a version-control revision, obtain the full canonical identifier directly from version control and preserve it verbatim. ## On Activation diff --git a/skills/bmad-build/step-01-clarify-and-route.md b/skills/bmad-build/step-01-clarify-and-route.md index 514c34053f..e3cf01a582 100644 --- a/skills/bmad-build/step-01-clarify-and-route.md +++ b/skills/bmad-build/step-01-clarify-and-route.md @@ -27,12 +27,12 @@ Before listing artifacts, resolve existing workflow state in this order. Skip th 3. The ticket tree With no argument and no intent from the conversation, run `uv run {project-root}/_bmad/method/scripts/tickets.py --project-root {project-root} next`. - Non-zero exit (no active initiative, a store refusal, a malformed tree) → say in one line that the ticket tree is unavailable and why, then go to 4. - - A row in any group whose `status` is `draft`, `ready-for-dev`, `in-progress`, or `in-review` has a started plan when the file at `find `'s `plan` exists. When any row has one, or `{{ config.output_folder }}/{active_initiative}/` holds a `plan-*.md` with one of those statuses, go to 4. + - A row in any group whose `status` is `draft`, `ready-for-dev`, `in-progress`, or `in-review` has a started plan when the file at `find `'s `plan` exists. When any row has one, or `{{ initiative_folder }}/` holds a `plan-*.md` with one of those statuses, go to 4. - No `ready_to_start` row → say in one line that nothing in the tree is ready, naming what is ready to refine, in progress, or blocked, then go to 4. - Otherwise run `find ` with the first `ready_to_start` row's `ref`, tell the user in one line which entry you are building, and follow **Ticket resolution**. 4. Otherwise — scan artifacts and ask - - Active plans (`draft`, `ready-for-dev`, `in-progress`, `in-review`) among `{{ config.output_folder }}/{active_initiative}/plan-*.md`, or started plans in the tree from branch 3? → List them all and HALT. Give the user a choice: + - Active plans (`draft`, `ready-for-dev`, `in-progress`, `in-review`) among `{{ initiative_folder }}/plan-*.md`, or started plans in the tree from branch 3? → List them all and HALT. Give the user a choice: - Resume one of the listed plans - **Next entry** — when branch 3 found a `ready_to_start` row with no `status`, the first one: run `find ` with its `ref` and follow **Ticket resolution** - **New** — start new work @@ -56,8 +56,12 @@ This runs on the output of `tickets.py find` for one ticket. Find's `description 1. Load context. - **A ticket from the tree** — when **Ticket resolution** set `plan_file`: the entry, its epic file and what that file's References name, and the story file when there is one are already the intent. For continuity, read the plans beside `plan_file` whose `ticket` is one of find's `after` ids that is a plain number (an entry of the same epic; a ref such as `1.5` is another epic's). Extract each one's **Code Map**, **Design Notes**, **Plan Change Log**, and task list as continuity context for step-02 planning. - **Anything else:** - - No `{active_initiative}`: unless the user already said in this session, ask once whether this work belongs to an initiative (hand off to the `bmad` skill to set one, then read `{active_initiative}` again) or is loose. - - List `{{ config.output_folder }}/{active_initiative}/`, then `{{ config.output_folder }}/`. + {% if initiative_folder == config.output_folder %} + - No initiative is active: unless the user already said in this session, ask once whether this work belongs to an initiative (hand off to the `bmad` skill to set one, then run this skill again) or is loose. + - List `{{ config.output_folder }}/`. + {% else %} + - List `{{ initiative_folder }}/`, then `{{ config.output_folder }}/`. + {% endif %} - If you find an unformatted plan or intent file, ingest its contents to form your understanding of the intent. - Planning documents sit in folders by type, main file named after the folder. Typical ones: - **PRD** (`prd-*/prd-*.md`) — product requirements and success criteria @@ -74,7 +78,7 @@ This runs on the output of `tickets.py find` for one ticket. Find's `description - HALT and give the user a choice: - **Split** — pick first goal, defer the rest. - **Keep all goals** — accept the risks. - - If the user chooses **Split**: For each deferred goal, append one new entry to `{{ config.output_folder }}/{active_initiative}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates. Narrow scope to the first-mentioned goal. Continue routing. + - If the user chooses **Split**: For each deferred goal, append one new entry to `{{ initiative_folder }}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates. Narrow scope to the first-mentioned goal. Continue routing. ```markdown - source_plan: none summary: @@ -83,7 +87,7 @@ This runs on the output of `tickets.py find` for one ticket. Find's `description - If the user chooses **Keep all goals**: Proceed as-is. 5. Set the plan file. - Derive a valid kebab-case slug from the current 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 `{{ config.output_folder }}/{active_initiative}/plan-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `plan_file` to that path, **EARLY EXIT** → `{{ rendered("step-02-plan.md") }}`); otherwise append `-2`, `-3`, etc. Set `plan_file` = `{{ config.output_folder }}/{active_initiative}/plan-{slug}.md`. + Derive a valid kebab-case slug from the current 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 `{{ initiative_folder }}/plan-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `plan_file` to that path, **EARLY EXIT** → `{{ rendered("step-02-plan.md") }}`); otherwise append `-2`, `-3`, etc. Set `plan_file` = `{{ initiative_folder }}/plan-{slug}.md`. ## NEXT diff --git a/skills/bmad-build/step-02-plan.md b/skills/bmad-build/step-02-plan.md index f0ef06c4eb..80e19562e3 100644 --- a/skills/bmad-build/step-02-plan.md +++ b/skills/bmad-build/step-02-plan.md @@ -35,7 +35,7 @@ 5. Self-review against READY FOR DEVELOPMENT standard. For anything important that's missing: if the repository can tell you, go look and fix the plan; if a human has to decide, add an `## Open Questions` entry. Do not invent the answer. 6. Resolve the gates before the checkpoint. Two things must be settled, in whatever order the conversation makes natural; combine them in one message when both apply. - **Token count** (see SCOPE STANDARD). If the plan exceeds 1600 tokens, show the count and give the user a choice: - - **Split** — carve off secondary goals. Propose the split — name each secondary goal. For each deferred goal, append one new entry to `{{ config.output_folder }}/{active_initiative}/deferred-work.md` using the format below. Do not modify existing entries or look for duplicates. Rewrite the current plan to cover only the main goal — do not surgically carve sections out; regenerate the plan for the narrowed scope. + - **Split** — carve off secondary goals. Propose the split — name each secondary goal. For each deferred goal, append one new entry to `{{ initiative_folder }}/deferred-work.md` using the format below. Do not modify existing entries or look for duplicates. Rewrite the current plan to cover only the main goal — do not surgically carve sections out; regenerate the plan for the narrowed scope. - **Keep full plan** — accept the risks. ```markdown - source_plan: `{plan_file}` diff --git a/skills/bmad-build/step-04-review.md b/skills/bmad-build/step-04-review.md index a2aec701d5..4a6507402e 100644 --- a/skills/bmad-build/step-04-review.md +++ b/skills/bmad-build/step-04-review.md @@ -95,7 +95,7 @@ Write `lenses_ran` — the ids launched, in launch order — to `{plan_file}` fr ``` If it cannot be continued, apply the patches yourself. Then re-run the checks in `{plan_file}`'s `## Verification` section, if present — the patches changed code after the implementer's verification; if verification fails and the failure cannot be fixed, HALT and escalate to the human. Rewrite `{diff_file}` so it reflects the patched tree. - - **defer** — Append one new entry to `{{ config.output_folder }}/{active_initiative}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates. + - **defer** — Append one new entry to `{{ initiative_folder }}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates. ```markdown - source_plan: `{plan_file}` summary: diff --git a/skills/bmad-build/step-oneshot.md b/skills/bmad-build/step-oneshot.md index 5054900826..ad0030f64a 100644 --- a/skills/bmad-build/step-oneshot.md +++ b/skills/bmad-build/step-oneshot.md @@ -83,7 +83,7 @@ For each group: - **patch** — This change caused or exposed the problem. The smallest fix is simple, adds no new public API, and does not guard code paths you did not show are reachable. Fix it now. - **HALT** — Same as patch, but the smallest fix is not that simple. Stop and ask the user before continuing. -- **defer** — Everything else: old bugs not caused by this change, ideas for later, groups where every member is `maybe-false` and would be `medium` or `high` if true (record that severity marked unverified, and what would prove it; if it would only be `low`, reject it), or fixes that would edit CLAUDE.md, AGENTS.md, rules, or specs. Add one entry to `{{ config.output_folder }}/{active_initiative}/deferred-work.md`: +- **defer** — Everything else: old bugs not caused by this change, ideas for later, groups where every member is `maybe-false` and would be `medium` or `high` if true (record that severity marked unverified, and what would prove it; if it would only be `low`, reject it), or fixes that would edit CLAUDE.md, AGENTS.md, rules, or specs. Add one entry to `{{ initiative_folder }}/deferred-work.md`: ```markdown - source_plan: `{plan_file}` diff --git a/skills/bmad-build/workflow.md b/skills/bmad-build/workflow.md index 8ef2cdb821..c9e8099560 100644 --- a/skills/bmad-build/workflow.md +++ b/skills/bmad-build/workflow.md @@ -34,7 +34,6 @@ A plan should target a **single user-facing goal** within **900–1600 tokens**: - Every operational cross-file reference in this workflow is an absolute snapshot path. Open it directly; do not resolve it relative to a skill directory. - `{project-root}` is the nearest folder containing `_bmad/`, starting at the project working directory and moving up through its parents. -- `{active_initiative}` is the value printed by `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key core.active_initiative`, read once before step 1. When it is unset, drop `/{active_initiative}` from every path. - Whenever this workflow captures or records a version-control revision, obtain the full canonical identifier directly from version control and preserve it verbatim. ## On Activation diff --git a/skills/bmad-code-review/step-02-review.md b/skills/bmad-code-review/step-02-review.md index 2abf560296..ccbd2cf7c1 100644 --- a/skills/bmad-code-review/step-02-review.md +++ b/skills/bmad-code-review/step-02-review.md @@ -23,7 +23,7 @@ failed_layers: '' # set at runtime: comma-separated list of lenses that failed o {{ workflow.thorough_lenses }} {% endif %} -3. If a lens's instruction requires subagents and none are available, for each such lens write that lens's child prompt beside `{plan_file}`, named after it with the lens id appended (in `{{ config.output_folder }}/{active_initiative}/` when there is no plan), with every file it points to — the diff, the claims, the reviewer instruction file — replaced inline by that file's contents, and every other line left exactly as written. That session shares no filesystem with this one, so its prompt has to stand alone; this is the only place you read a reviewer instruction file yourself. Then HALT. Ask the user to run each in a separate session (ideally a different LLM) and paste back the findings. When findings are pasted, treat them as those lenses' findings and resume from this point. +3. If a lens's instruction requires subagents and none are available, for each such lens write that lens's child prompt beside `{plan_file}`, named after it with the lens id appended (in `{{ initiative_folder }}/` when there is no plan), with every file it points to — the diff, the claims, the reviewer instruction file — replaced inline by that file's contents, and every other line left exactly as written. That session shares no filesystem with this one, so its prompt has to stand alone; this is the only place you read a reviewer instruction file yourself. Then HALT. Ask the user to run each in a separate session (ideally a different LLM) and paste back the findings. When findings are pasted, treat them as those lenses' findings and resume from this point. 4. **Lens failure handling**: If any lens fails, times out, or returns empty results, append the lens's `name` to `failed_layers` (comma-separated) and proceed with findings from the remaining lenses. diff --git a/skills/bmad-code-review/step-04-present.md b/skills/bmad-code-review/step-04-present.md index 299f9c8bcf..b85c305a29 100644 --- a/skills/bmad-code-review/step-04-present.md +++ b/skills/bmad-code-review/step-04-present.md @@ -1,5 +1,5 @@ --- -deferred_work_file: '{{ config.output_folder }}/{active_initiative}/deferred-work.md' +deferred_work_file: '{{ initiative_folder }}/deferred-work.md' --- # Step 4: Present and Act diff --git a/skills/bmad-code-review/workflow.md b/skills/bmad-code-review/workflow.md index 947df1b443..d58fbfd946 100644 --- a/skills/bmad-code-review/workflow.md +++ b/skills/bmad-code-review/workflow.md @@ -16,7 +16,6 @@ If you need an explicit user instruction to run them, ask once now for the whole - Every operational cross-file reference in this workflow is an absolute snapshot path. Open it directly; do not resolve it relative to a skill directory. - `{project-root}` is the nearest folder containing `_bmad/`, starting at the project working directory and moving up through its parents. - `{date}` is the current system datetime. -- `{active_initiative}` is the value printed by `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key core.active_initiative`, read once before step 1. When it is unset, drop `/{active_initiative}` from every path. ## On Activation diff --git a/skills/bmad-toolsmith/shapes/rendered-skill/shape.md b/skills/bmad-toolsmith/shapes/rendered-skill/shape.md index 05f8bc4c1a..a85e37adb6 100644 --- a/skills/bmad-toolsmith/shapes/rendered-skill/shape.md +++ b/skills/bmad-toolsmith/shapes/rendered-skill/shape.md @@ -14,6 +14,6 @@ A skill whose Markdown is rendered once per run from `customize.toml` and the te ## Rules - `SKILL.md` does one thing: runs `uv run --no-cache "{project-root}/_bmad/scripts/render_skill.py" --project-root "{project-root}" --skill "{skill-root}"`, maps the words of the invocation to `--set workflow.=`, follows the printed `workflow.md`, and halts on any failure. No Jinja in it; the renderer excludes it. -- Jinja (`{{ workflow.key }}`, `{% if %}`, `rendered("step-02-.md")`) lives only in `workflow.md` and the steps. An agent-facing `{{placeholder}}` sits inside `{% raw %}...{% endraw %}`; a single-brace `{placeholder}` passes through untouched. A customization value the templates cannot act on, such as a misspelled selector, is rejected at the top of `workflow.md` with `{{ halt("...") }}`, one guard per selector. +- Jinja (`{{ workflow.key }}`, `{% if %}`, `rendered("step-02-.md")`) lives only in `workflow.md` and the steps. An agent-facing `{{placeholder}}` sits inside `{% raw %}...{% endraw %}`; a single-brace `{placeholder}` passes through untouched. The skill's documents go under `{{ initiative_folder }}`, the output folder extended by the active initiative when one is set. A customization value the templates cannot act on, such as a misspelled selector, is rejected at the top of `workflow.md` with `{{ halt("...") }}`, one guard per selector. - `customize.toml` holds `[workflow]` with `activation_steps_prepend`, `activation_steps_append` and `persistent_facts`. A selector is a scalar with its allowed values in the comment above it; an instruction a team may replace whole is a `"""` block scalar. Teams override in `{project-root}/_bmad/custom/.toml` or `.user.toml`; a single run overrides with `--set`. - Each step is loaded when reached, says at the top what it produces, and ends by naming the next step as `{{ rendered("step-NN-.md") }}` or by saying the workflow is complete. A step that shows a menu halts and waits for the user. No file whose name contains `template` carries `{{ config.* }}` or `{{ workflow.* }}`. diff --git a/skills/bmad-walkthrough/step-02-narrative.md b/skills/bmad-walkthrough/step-02-narrative.md index e53ac936a0..f826d03a10 100644 --- a/skills/bmad-walkthrough/step-02-narrative.md +++ b/skills/bmad-walkthrough/step-02-narrative.md @@ -2,7 +2,7 @@ Write the review narrative and the review log in a new folder `walkthrough-/` under -`{{ config.output_folder }}/{active_initiative}/`: the narrative as +`{{ initiative_folder }}/`: the narrative as `walkthrough-.md`, the log as `walkthrough--log.md`. `` is a short review slug; check that the folder name is unused before creating it. diff --git a/skills/bmad-walkthrough/workflow.md b/skills/bmad-walkthrough/workflow.md index b81de1e0b0..b65e36238f 100644 --- a/skills/bmad-walkthrough/workflow.md +++ b/skills/bmad-walkthrough/workflow.md @@ -90,11 +90,6 @@ every match. Other entries are facts. {% endif %} # Workflow -`{active_initiative}` is the value printed by -`uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key core.active_initiative`, -read once before step 1. When it is unset, drop `/{active_initiative}` -from every path. - 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. diff --git a/skills/bmad/scripts/render_skill.py b/skills/bmad/scripts/render_skill.py index e7e2f754de..9ef0aa9b7b 100644 --- a/skills/bmad/scripts/render_skill.py +++ b/skills/bmad/scripts/render_skill.py @@ -15,6 +15,7 @@ import shutil import sys import tomllib +from collections.abc import Callable from datetime import date, time from pathlib import Path from typing import Any @@ -283,6 +284,13 @@ def __iter__(self): raise RenderError(f"`{self.label}` is a string, not a list") +class _Derived: + """A name the renderer computes from the central config, when a template reaches it.""" + + def __init__(self, compute: Callable[[], Any]) -> None: + self.compute = compute + + class _MarkdownList(list): """A string-list customization; inserted directly it renders as the Markdown list it always did.""" @@ -418,7 +426,19 @@ def __init__( ), "rendered": self._rendered, "halt": self._halt, + "initiative_folder": _Derived(self._initiative_folder), } + self._central = central + + def _initiative_folder(self) -> _Text: + """`core.output_folder`, extended by `/` when an initiative is set.""" + folder = str(self.variables["config"].core.output_folder) + core = self._central.get("core") + initiative = core.get("active_initiative") if isinstance(core, dict) else None + if initiative is not None: + folder = f"{folder}/{_require_string(initiative, 'config.core.active_initiative')}" + self.inputs["initiative_folder"] = folder + return _Text(folder, "initiative_folder") @staticmethod def _halt(message: Any) -> str: @@ -433,6 +453,14 @@ def _rendered(self, context: jinja2.runtime.Context, target: Any) -> str: return (self.destination / target).as_posix() +class _Context(jinja2.runtime.Context): + """Reaching a derived name computes it, so only the values a template reads key its generation.""" + + def resolve_or_missing(self, key: str) -> Any: + value = super().resolve_or_missing(key) + return value.compute() if isinstance(value, _Derived) else value + + class _SourceLoader(jinja2.BaseLoader): """Serve sources by name, and name them so template frames carry `source:line`.""" @@ -471,6 +499,7 @@ def _render_sources(sources: dict[str, str], skill_dir: Path, context: _RenderCo trim_blocks=True, lstrip_blocks=True, ) + environment.context_class = _Context rendered: dict[str, str] = {} for name in sources: try: diff --git a/skills/bmad/scripts/tests/test_render_skill.py b/skills/bmad/scripts/tests/test_render_skill.py index ec4d46117c..8af7904862 100644 --- a/skills/bmad/scripts/tests/test_render_skill.py +++ b/skills/bmad/scripts/tests/test_render_skill.py @@ -39,7 +39,7 @@ ) SHIPPED_SKILLS = ("bmad-build-auto", "bmad-build", "bmad-code-review") RENDERED_SKILLS = (*SHIPPED_SKILLS, "bmad-walkthrough", "bmad-retrospective") -COMPILE_TOKEN = re.compile(r"\{\{\s*(?:config|workflow)\.|\{\{\s*rendered\(|\{%") +COMPILE_TOKEN = re.compile(r"\{\{\s*(?:config|workflow)\.|\{\{\s*(?:rendered\(|initiative_folder)|\{%") DISPATCH_PREFIX = "read and follow " sys.path.insert(0, str(SCRIPTS_SRC)) @@ -607,6 +607,20 @@ def test_rendered_skills_publish_snapshots_without_skill_root(self): workflow = rs.render(ws.project, skill) self._assert_rendered(workflow, ws.project, name) + def test_build_skills_render_the_active_initiative_branch(self): + for name in ("bmad-build", "bmad-build-auto"): + with self.subTest(name): + ws = self._workspace() + (ws.bmad / "custom" / "config.user.toml").write_text( + '[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8" + ) + skill = self._skill(ws, name) + snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, name) + markdown = _markdown(snap) + folder = (ws.project.resolve() / "_bmad-output" / "initiative-checkout").as_posix() + self.assertIn(f"{folder}/plan-{{slug}}.md", markdown) + self.assertNotIn("No initiative is active", markdown) + def test_build_skills_render_each_pinned_route(self): for name in ("bmad-build", "bmad-build-auto"): for route in ("oneshot", "full"): @@ -685,6 +699,29 @@ def test_referenced_config_and_source_changes_publish_new_generations(self): for name, content in before_files.items(): self.assertEqual(current[name], content, name) + def test_initiative_folder_follows_the_active_initiative_where_a_template_reaches_it(self): + ws = self._workspace() + skill = self._fixture_skill(ws, "[workflow]\n", "{{ initiative_folder }}\n") + user_config = ws.bmad / "custom" / "config.user.toml" + output_folder = (ws.project.resolve() / "_bmad-output").as_posix() + unset = rs.render(ws.project, skill) + self.assertEqual(unset.read_text(encoding="utf-8"), f"{output_folder}\n") + user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8") + active = rs.render(ws.project, skill) + self.assertNotEqual(active, unset) + self.assertEqual(active.read_text(encoding="utf-8"), f"{output_folder}/initiative-checkout\n") + for value, message in (("42", "must be a string"), ('""', "must not be empty")): + with self.subTest(value=value): + user_config.write_text(f"[core]\nactive_initiative = {value}\n", encoding="utf-8") + with self.assertRaisesRegex(rs.RenderError, f"config.core.active_initiative {message}"): + rs.render(ws.project, skill) + + (skill / "workflow.md").write_text("plain\n", encoding="utf-8") + user_config.unlink() + unreferenced = rs.render(ws.project, skill) + user_config.write_text('[core]\nactive_initiative = "initiative-checkout"\n', encoding="utf-8") + self.assertEqual(rs.render(ws.project, skill), unreferenced) + def test_shared_runtime_keeps_distinct_root_bound_snapshots(self): first = self._workspace() skill = self._skill(first, "bmad-build") diff --git a/tools/skill-validator.md b/tools/skill-validator.md index d843ff4735..e1f1199147 100644 --- a/tools/skill-validator.md +++ b/tools/skill-validator.md @@ -63,6 +63,7 @@ Path resolution differs between the last two; see PATH-01. | `{{ workflow.key }}` | `render_skill.py`, at render time | rendered skills only | | `{{ config.key }}`, `{{ config.a.b.c }}` | `render_skill.py`, at render time | rendered skills only | | `{{ rendered("file.md") }}` | `render_skill.py`, at render time | rendered skills only | +| `{{ initiative_folder }}` | `render_skill.py`, at render time | rendered skills only | | `{{name}}` (no `config.` or `workflow.`) | nothing — survives verbatim into the generated artifact | templates and the artifacts they seed; inside `{% raw %}` in a rendered skill | The distinction between `{{name}}` and `{{ config.name }}` matters: the first is an artifact placeholder the consumer of the generated document fills in later; the second is a value baked in at render time. See REF-01 and TPL-01. @@ -83,10 +84,11 @@ Instructions for the full route. {% endfor %} ``` -Templates see four names: +Templates see five names: - `config` — the central config. `config.key` is the one scalar with that key anywhere in the merged config (an ambiguous or missing key halts); `config.a.b.c` names an explicit path. `{project-root}` in the value is bound. - `workflow` — the effective customization's `[workflow]` table: shipped `customize.toml`, then project and user TOML, then invocation overrides. Each value is validated against the shape of its shipped default. Inserted directly, a string list renders as a Markdown list and a list of lens tables as lens sections, the same output the pre-Jinja2 tokens produced; `{% for %}` iterates either. `{skill-root}` in a value is bound to the generation directory. +- `initiative_folder` — `config.output_folder`, extended by `/` when `core.active_initiative` is set in the central config: the folder the project's documents and tickets go to. Reaching it keys the generation like a config value. - `rendered("file.md")` — the generation path of another rendered source. The target must be a Markdown file in the skill other than `SKILL.md`, which the renderer excludes. - `halt(message)` — stops the render with that message, prefixed by the source and line. Use it to reject a customization value the templates cannot act on, such as a misspelled selector. @@ -265,8 +267,8 @@ Every value reached during the render is part of the generation's identity. Cust - **Severity:** HIGH - **Applies to:** `.md` files whose name contains `template` (case-insensitive) -- **Rule:** Template files become artifacts (for example plan files) that are committed and used on other machines. `render_skill.py` would replace a `{{ config.key }}` or `{{ workflow.key }}` expression with a value from the rendering machine, and every artifact produced from the template would carry it. -- **Detection:** Regex `\{\{-?\s*(?:config|workflow)\.[^}]*\}\}` match anywhere in a file whose basename matches `/template/i`. +- **Rule:** Template files become artifacts (for example plan files) that are committed and used on other machines. `render_skill.py` would replace a `{{ config.key }}`, `{{ workflow.key }}` or `{{ initiative_folder }}` expression with a value from the rendering machine, and every artifact produced from the template would carry it. +- **Detection:** Regex `\{\{-?\s*(?:(?:config|workflow)\.|initiative_folder\b)[^}]*\}\}` match anywhere in a file whose basename matches `/template/i`. - **Fix:** Remove the expression. Use single-curly `{var}` if the value should be resolved at runtime by the consumer of the generated artifact, or plain double-curly `{{var}}` if it is a placeholder the consumer fills in. --- @@ -279,10 +281,10 @@ Every value reached during the render is part of the generation's identity. Cust - `{name}` — a frontmatter variable in the same file, a config key, a runtime variable set during execution, or the path anchors `{project-root}` and `{skill-root}`. - `{workflow.key}` — must name a key in the `[workflow]` table of the skill's own `customize.toml`. - `{agent.key}` — must name a key in the `[agent]` table of the skill's own `customize.toml`. - - `{{ workflow.key }}`, `{{ config.key }}`, `{{ rendered("file.md") }}` and `{% %}` tags — only in a rendered skill (one whose SKILL.md invokes `render_skill.py`). In any other skill nothing will render them and they reach the agent verbatim. `workflow.key` must name a key in the skill's own `customize.toml`; a `rendered()` target must name a Markdown file in the skill other than `SKILL.md`, which the renderer excludes from its source set. + - `{{ workflow.key }}`, `{{ config.key }}`, `{{ initiative_folder }}`, `{{ rendered("file.md") }}` and `{% %}` tags — only in a rendered skill (one whose SKILL.md invokes `render_skill.py`). In any other skill nothing will render them and they reach the agent verbatim. `workflow.key` must name a key in the skill's own `customize.toml`; a `rendered()` target must name a Markdown file in the skill other than `SKILL.md`, which the renderer excludes from its source set. - **Detection:** Collect all tokens in the file and classify them by form. Resolve config keys against the `prompt:` keys in `module.yaml`; resolve `{workflow.*}`, `{agent.*}`, and `workflow.*` expressions against the skill's `customize.toml`. Before flagging a render-time expression, grep the skill's `SKILL.md` for `render_skill.py` — if it is a rendered skill, the expression is legitimate. Flag any token that cannot be traced to a source. - **Exceptions:** - - Plain double-curly `{{name}}` with **no** `config.` or `workflow.` prefix — an artifact placeholder that survives rendering into the generated document, to be filled in by whoever consumes it (e.g. `{{story_key}}` in a story template). Do not flag these; in a rendered skill they must sit inside `{% raw %}`. `{{ config.key }}` and `{{ workflow.key }}` are **not** covered by this exception; they are render-time expressions governed by the rule above and by TPL-01. + - Plain double-curly `{{name}}` with **no** `config.` or `workflow.` prefix — an artifact placeholder that survives rendering into the generated document, to be filled in by whoever consumes it (e.g. `{{story_key}}` in a story template). Do not flag these; in a rendered skill they must sit inside `{% raw %}`. `{{ config.key }}`, `{{ workflow.key }}` and `{{ initiative_folder }}` are **not** covered by this exception; they are render-time expressions governed by the rule above and by TPL-01. - Variables inside fenced code blocks that are clearly illustrative examples. - **Fix:** Either define the variable in the appropriate `customize.toml` table or frontmatter, or replace the reference with a literal value. If a config key was misspelled, correct the spelling. diff --git a/tools/validate_skills.py b/tools/validate_skills.py index 1466ea5f43..beb04722ef 100644 --- a/tools/validate_skills.py +++ b/tools/validate_skills.py @@ -56,7 +56,7 @@ re.compile(r"\bETA\b"), ] TEMPLATE_FILENAME_REGEX = re.compile(r"template", re.I) -COMPILE_TIME_SUB_REGEX = re.compile(r"\{\{-?\s*(?:config|workflow)\.[^}]*\}\}") +COMPILE_TIME_SUB_REGEX = re.compile(r"\{\{-?\s*(?:(?:config|workflow)\.|initiative_folder\b)[^}]*\}\}") INSTALLED_PATH_RE = re.compile(r"installed_path", re.I) BARE_SCRIPT_RE = re.compile(r"\buv\s+run\b[^`]*?\s(?:\./)?scripts/") USE_WHEN_RE = re.compile(r"use\s+when\b", re.I) @@ -555,7 +555,7 @@ def validate_skill(skill_dir: str) -> list[dict]: "HIGH", rel_file, f"Template file contains render-time expression `{match.group(0)}` — this would be baked at render time and leak a machine-local value into every spec produced from the template.", - "Remove the `{{ config.key }}` or `{{ workflow.key }}` expression. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated spec.", + "Remove the `{{ config.key }}`, `{{ workflow.key }}` or `{{ initiative_folder }}` expression. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated spec.", line=i + 1, ) )