refactor(renderer): render skill sources as Jinja2 templates (#2857)

Replace the regex token substitution and the line-parsed bmad-if
directives in render_skill.py with Jinja2. Templates see config,
workflow, and snapshot(); undefined names, empty entry files, and
links to omitted sources halt; every value a render reaches and the
Jinja2 version key the generation. Migrate the five rendered skills'
sources to the new forms, keep their shipped output byte-identical,
and update the validator rule and the authoring docs.
This commit is contained in:
Alex Verkhovsky
2026-09-11 16:45:10 -06:00
committed by GitHub
parent 116e703d67
commit 94b6727b00
37 changed files with 643 additions and 392 deletions
-2
View File
@@ -274,8 +274,6 @@ uv run /abs/project/_bmad/scripts/render_skill.py \
Keys are dotted parameter paths such as `workflow.on_complete`. The
override file has the same shape as the skill's `customize.toml`. Both
layer on top of the persistent files, and `--set` wins over the file.
Every override must reach a token or condition in the rendered skill;
an override the render does not use halts.
String values can be written as plain text. Other types use TOML syntax:
+1
View File
@@ -15,6 +15,7 @@ dependencies = []
[dependency-groups]
dev = [
"jinja2>=3.1",
"pre-commit>=4.6.2",
"pytest>=8",
"pytest-xdist>=3",
@@ -17,11 +17,11 @@ followup_pass: '' # set at runtime when a `done` spec is re-dispatched for a fol
Use the invocation prompt as the intent.
If the invocation prompt explicitly points to an existing spec file with recognized `status` frontmatter, set `spec_file`, then **EARLY EXIT** to the appropriate step:
- `draft``[[bmad-snapshot:step-02-plan.md]]`
- `ready-for-dev` or `in-progress``[[bmad-snapshot:step-03-implement.md]]`
- `in-review``[[bmad-snapshot:step-04-review.md]]`
- `draft``{{ rendered("step-02-plan.md") }}`
- `ready-for-dev` or `in-progress``{{ rendered("step-03-implement.md") }}`
- `in-review``{{ rendered("step-04-review.md") }}`
- `blocked` → HALT with status `blocked` and blocking condition `blocked spec supplied`.
- `done` → set `review_loop_iteration` to `0` in the frontmatter and set `followup_pass` to `true`, then **EARLY EXIT** to `[[bmad-snapshot:step-04-review.md]]` for a fresh review pass. (A `done` spec is a completed run, so this starts a follow-up review, not a resumption.)
- `done` → set `review_loop_iteration` to `0` in the frontmatter and set `followup_pass` to `true`, then **EARLY EXIT** to `{{ rendered("step-04-review.md") }}` for a fresh review pass. (A `done` spec is a completed run, so this starts a follow-up review, not a resumption.)
If the invocation prompt instead supplies a spec folder and a story id, with no specific spec file path, this is a **folder+id dispatch**: set `spec_folder` (a `{project-root}`-relative or absolute path) and `story_id` from the prompt. Any further prompt text (e.g. `invoke_dev_with` guidance the caller appended) is additional planning context to carry into step-02 — not a competing description of what to implement.
@@ -30,7 +30,7 @@ Read `{spec_folder}/stories.yaml`. If the file does not exist or fails to parse,
Look for files matching `{spec_folder}/stories/{story_id}-*.md` (id-prefix match — story ids are prefix-free, so at most one should match):
- **If more than one matches**, HALT with status `blocked` and blocking condition `ambiguous story file match`.
- **If exactly one matches**, set `spec_file` to that path.
- `draft` (planning was interrupted mid-flight): accumulate cross-story context before resuming — load every other file matching `{spec_folder}/stories/*.md` (every match except `{spec_file}` itself), regardless of `status`, and carry forward each one's **Code Map**, **Design Notes**, **Implementation Notes**, **Spec Change Log**, **Tasks & Acceptance**, and **Auto Run Result** details, where present, as additional planning context for step-02. Then **EARLY EXIT** to `[[bmad-snapshot:step-02-plan.md]]`.
- `draft` (planning was interrupted mid-flight): accumulate cross-story context before resuming — load every other file matching `{spec_folder}/stories/*.md` (every match except `{spec_file}` itself), regardless of `status`, and carry forward each one's **Code Map**, **Design Notes**, **Implementation Notes**, **Spec Change Log**, **Tasks & Acceptance**, and **Auto Run Result** details, where present, as additional planning context for step-02. Then **EARLY EXIT** to `{{ rendered("step-02-plan.md") }}`.
- Any other recognized `status`: **EARLY EXIT** using the same routing as above, including the `review_loop_iteration` reset and `followup_pass` for `done`. One difference: a `blocked` story HALTs with blocking condition `story already blocked`, not `blocked spec supplied` — the caller did not supply this file; build-auto found it by id.
- `status` missing or unrecognized: HALT with status `blocked` and blocking condition `unrecognized status in existing story file`.
- **If none matches**, this is the first dispatch for `{story_id}`. The entry's `title` and `description` are the resolved intent. If `{spec_folder}/SPEC.md` does not exist, HALT with status `blocked` and blocking condition `no epic spec found`. Otherwise load it and the files listed in its `companions:` frontmatter as planning context, then accumulate cross-story context the same way as the `draft` case above — load every file matching `{spec_folder}/stories/*.md` (none yet exists for `{story_id}` at this point, so nothing is excluded), regardless of `status`, carrying forward the same fields, where present, as additional planning context for step-02. Then continue to INSTRUCTIONS item 3 below — not `step-03-implement.md`, item 3 of the numbered list in this file (items 1 and 2 do not apply — context and intent are already resolved; item 1.A.5's previous-story continuity scan in particular never runs here, since folder+id dispatch already skips items 1 and 2 entirely — the cross-story accumulation above is its replacement for this dispatch mode).
@@ -43,7 +43,7 @@ If the invocation prompt does not contain enough intent to identify what to impl
## INSTRUCTIONS
1. Load context.
- List files in `{{.planning_artifacts}}` and `{{.implementation_artifacts}}`.
- List files in `{{ config.planning_artifacts }}` and `{{ config.implementation_artifacts }}`.
- If the invocation prompt points to an unformatted spec or intent file, ingest that file. Do not scan for unrelated intent files.
- **Determine context strategy.** Using the intent and the artifact listing, infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the listing, and any epics file content together.
@@ -51,15 +51,15 @@ If the invocation prompt does not contain enough intent to identify what to impl
1. Identify the epic number `{epic_num}` and (if present) the story number `{story_num}`. If you can't identify an epic number, use path B.
2. **Check for a valid cached epic context.** Look for `{{.implementation_artifacts}}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{{.planning_artifacts}}` is newer.
2. **Check for a valid cached epic context.** Look for `{{ config.implementation_artifacts }}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{{ config.planning_artifacts }}` is newer.
- **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.).
- **If missing, empty, or invalid:** compile it in the next bullet.
3. **Compile epic context if needed.** If no valid cached epic context was loaded, produce `{{.implementation_artifacts}}/epic-<N>-context.md` by spawning a subagent synchronously with `[[bmad-snapshot:compile-epic-context.md]]` as its prompt. Pass it the epic number, epics file path, `{{.planning_artifacts}}`, and output path `{{.implementation_artifacts}}/epic-<N>-context.md`.
3. **Compile epic context if needed.** If no valid cached epic context was loaded, produce `{{ config.implementation_artifacts }}/epic-<N>-context.md` by spawning a subagent synchronously with `{{ rendered("compile-epic-context.md") }}` as its prompt. Pass it the epic number, epics file path, `{{ config.planning_artifacts }}`, and output path `{{ config.implementation_artifacts }}/epic-<N>-context.md`.
4. **Verify if compiled.** If epic context was compiled, verify the output file exists, is non-empty, and starts with `# Epic <N> Context:`. If valid, load it. If verification fails, HALT with status `blocked` and blocking condition `context compilation verification failed`.
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{{.implementation_artifacts}}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Implementation Notes**, **Spec Change Log**, and **Tasks & Acceptance**, where present, as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, HALT with status `blocked` and blocking condition `missing previous-story continuity decision`.
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{{ config.implementation_artifacts }}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Implementation Notes**, **Spec Change Log**, and **Tasks & Acceptance**, where present, as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, HALT with status `blocked` and blocking condition `missing previous-story continuity decision`.
**B) Freeform path** — if the intent is not an epic story:
- Planning artifacts are the output of BMAD phases 1-3. Typical files include:
@@ -74,10 +74,10 @@ If the invocation prompt does not contain enough intent to identify what to impl
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 `{spec_file}` frontmatter `warnings`. Do not split or block.
5. Route:
**Folder+id dispatch:** derive a valid kebab-case slug from the entry's `title` (and `description` if needed) — the same kebab-casing convention as below, but never prefixed with `{story_id}`, since the id is already the filename's separate leading segment. Set `spec_file` = `{spec_folder}/stories/{story_id}-{slug}.md`. The id already disambiguates: no `{{.implementation_artifacts}}` fallback, no `-2`/`-3` suffixing.
**Folder+id dispatch:** derive a valid kebab-case slug from the entry's `title` (and `description` if needed) — the same kebab-casing convention as below, but never prefixed with `{story_id}`, since the id is already the filename's separate leading segment. Set `spec_file` = `{spec_folder}/stories/{story_id}-{slug}.md`. The id already disambiguates: no `{{ config.implementation_artifacts }}` fallback, no `-2`/`-3` suffixing.
**Otherwise:** 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 `{{.implementation_artifacts}}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT**`[[bmad-snapshot:step-02-plan.md]]`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{{.implementation_artifacts}}/spec-{slug}.md`.
**Otherwise:** 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.implementation_artifacts }}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT**`{{ rendered("step-02-plan.md") }}`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{{ config.implementation_artifacts }}/spec-{slug}.md`.
## NEXT
Read fully and follow `[[bmad-snapshot:step-02-plan.md]]`
Read fully and follow `{{ rendered("step-02-plan.md") }}`
+4 -4
View File
@@ -8,11 +8,11 @@
1. Draft resume check. If `{spec_file}` exists with `status: draft`, read it and capture the verbatim `<intent-contract>...</intent-contract>` block as `preserved_intent_contract`. Otherwise `preserved_intent_contract` is empty.
2. Investigate codebase. _Read the code yourself for narrow, localized tasks. Isolate deep exploration in synchronous subagents: instruct them to give you distilled summaries only, and plan from those summaries._ Decide which findings actually matter for execution — the specific files, symbols/lines, reuse points, and read-only constraints — and carry those forward for the Code Map. This is where the investigation lands: the spec preserves it so it is never re-narrated to the implementer at dispatch time.
3. {workflow.route_selection}
3. {{ workflow.route_selection }}
Irreversible steps (migrations, data mutation, external side effects) always take the full route.
4. Read `[[bmad-snapshot:spec-template.md]]` fully, preserving all frontmatter fields and resolving `date` to the current system date.
4. Read `{{ rendered("spec-template.md") }}` fully, preserving all frontmatter fields and resolving `date` to the current system date.
- **Oneshot:** set `route: 'oneshot'`.
- **Full:** set `route: 'full'`. Drain the investigation into `## Code Map` — annotated paths, symbol/line anchors, reuse pointers, and read-only evidence — so the handoff need only point at the spec.
@@ -22,7 +22,7 @@
### READY-FOR-DEVELOPMENT GATE
Re-read `[[bmad-snapshot:workflow.md]]`, then re-read `{spec_file}` from disk and verify the spec meets the READY FOR DEVELOPMENT standard.
Re-read `{{ rendered("workflow.md") }}`, then re-read `{spec_file}` from disk and verify the spec meets the READY FOR DEVELOPMENT standard.
- **If the file is missing:** HALT with status `blocked` and blocking condition `planned spec file disappeared before implementation`.
- **If the spec meets the standard:** set `{spec_file}` frontmatter status to `ready-for-dev`. If the invocation prompt directs a halt after planning (standard phrasing: `Halt after planning.` — accept any clear equivalent), HALT with status `ready-for-dev`; otherwise continue to step 3.
@@ -30,4 +30,4 @@ Re-read `[[bmad-snapshot:workflow.md]]`, then re-read `{spec_file}` from disk an
## NEXT
Read fully and follow `[[bmad-snapshot:step-03-implement.md]]`
Read fully and follow `{{ rendered("step-03-implement.md") }}`
+3 -3
View File
@@ -26,13 +26,13 @@ Change `{spec_file}` status to `in-progress` in the frontmatter before starting
Implement in this main session from the story's Intent and working notes. Do not launch an implementing subagent or execute the full-route handoff. Append decisions, files touched, and surprises to `## Implementation Notes`.
Stop and replan if the intent left out something the user would notice in the result, you need to do something you cannot undo, or the remaining work is substantially larger than anticipated. Record the trigger in `## Implementation Notes`, set `route: 'full'` and `status: 'draft'`, then read fully and follow `[[bmad-snapshot:step-02-plan.md]]`.
Stop and replan if the intent left out something the user would notice in the result, you need to do something you cannot undo, or the remaining work is substantially larger than anticipated. Record the trigger in `## Implementation Notes`, set `route: 'full'` and `status: 'draft'`, then read fully and follow `{{ rendered("step-02-plan.md") }}`.
#### Full (`route: full`, or a legacy spec with no route)
Substitute the runtime placeholders (e.g. `{spec_file}`) into the implementation handoff below, then follow it verbatim. Do not add parent-authored goal restatements, file lists, ownership boundaries, or acceptance criteria to the handoff — the spec is the subagent's sole source of truth. If the handoff conflicts with the spec, HALT with status `blocked` and blocking condition `handoff conflicts with spec`, and include both conflicting passages.
{workflow.implementation_handoff}
{{ workflow.implementation_handoff }}
Invoke the subagent **synchronously** and wait for it to return in this same turn — do not background/detach it (`run_in_background`) or end your turn to await a notification (see workflow.md → Subagents). Resume at "Verify" only after it returns. If the platform allows, keep the subagent available for re-engagement after it returns — step-04 may send it review fixes.
@@ -54,4 +54,4 @@ If `{spec_file}`'s intent-contract contains an I/O & Edge-Case Matrix, verify ev
## NEXT
Read fully and follow `[[bmad-snapshot:step-04-review.md]]`
Read fully and follow `{{ rendered("step-04-review.md") }}`
+3 -3
View File
@@ -23,7 +23,7 @@ Runtime placeholders: `{diff_file}` is the diff staged above and `{claims_file}`
Announce skipped layers first, then launch every active layer before handling any layer's result. Try running all active layers simultaneously: substitute the runtime placeholders (e.g. `{diff_file}`) into each layer's instruction. When an instruction launches a reviewer subagent, launch that child with the prompt text after placeholder substitution; do not load the reviewer instruction file yourself. For any other customized instruction, execute it as written. Parallel means several blocking calls awaited together in this turn — never backgrounded or detached, never ending the turn to await results (see workflow.md → Subagents). Spawn every reviewer subagent before reading or reacting to any of their output; begin collection and triage only once all are launched.
{workflow.review_layers}
{{ workflow.review_layers }}
### Classify
@@ -67,8 +67,8 @@ Announce skipped layers first, then launch every active layer before handling an
```
Where `{date}` is the current system date. One row per finding from every layer, in the order the layers reported them; `<total>` must equal the number of findings the layers reported — a finding missing from the log is a triage failure. Members of a grouped entry keep their own rows and share the route.
5. Process entries in cascading order. If intent_gap exists, lower entries are moot; follow the intent_gap branch below. If bad_spec exists, lower entries are moot since code will be re-derived. If neither exists, process patch and defer normally. Before each bad_spec loopback, read `{spec_file}` frontmatter `review_loop_iteration` (missing means `0`), increment it by 1, and write it back. If it exceeds 5, append the triage-log entry for this pass, then HALT with status `blocked` and blocking condition `review repair loop exceeded 5 iterations (non-convergence)`.
- **intent_gap** — Root cause is inside `<intent-contract>`. Save the attempted change as a patch file in `{{.implementation_artifacts}}` and reference it from the triage-log entry, then revert code changes. Append the triage-log entry for this pass, then HALT with status `blocked`, blocking condition `intent gap`, and include the unresolved questions and the saved patch path.
- **bad_spec** — Root cause is outside `<intent-contract>`. Do not modify content inside `<intent-contract>`. Before reverting code: extract KEEP instructions for positive preservation (what worked well and must survive re-derivation). Revert code changes. Read the `## Spec Change Log` in `{spec_file}` and strictly respect all logged constraints when amending the sections outside `<intent-contract>` that contain the root cause. Append a new change-log entry recording: the triggering finding, what was amended, the known-bad state avoided, and the KEEP instructions. Append the triage-log entry for this pass, recording in each bad_spec row the amendment it triggered. Read fully and follow `[[bmad-snapshot:step-03-implement.md]]` to re-derive the code, then this step will run again.
- **intent_gap** — Root cause is inside `<intent-contract>`. Save the attempted change as a patch file in `{{ config.implementation_artifacts }}` and reference it from the triage-log entry, then revert code changes. Append the triage-log entry for this pass, then HALT with status `blocked`, blocking condition `intent gap`, and include the unresolved questions and the saved patch path.
- **bad_spec** — Root cause is outside `<intent-contract>`. Do not modify content inside `<intent-contract>`. Before reverting code: extract KEEP instructions for positive preservation (what worked well and must survive re-derivation). Revert code changes. Read the `## Spec Change Log` in `{spec_file}` and strictly respect all logged constraints when amending the sections outside `<intent-contract>` that contain the root cause. Append a new change-log entry recording: the triggering finding, what was amended, the known-bad state avoided, and the KEEP instructions. Append the triage-log entry for this pass, recording in each bad_spec row the amendment it triggered. Read fully and follow `{{ rendered("step-03-implement.md") }}` to re-derive the code, then this step will run again.
- **patch** — Auto-fix. These are the only findings that survive loopbacks. On the full route, re-engage the step-03 implementation subagent — the same one, addressed by the name or id its launch returned; a fresh launch is not re-engagement. Send it one message, exactly this, with the findings filled in:
```text
+7 -7
View File
@@ -8,7 +8,7 @@
To HALT with a final status and optional blocking condition:
1. **Folder+id dispatch** (`{spec_folder}` and `{story_id}` are set): the write-back always lands at the id-keyed story spec. The `{{.implementation_artifacts}}` fallback in step 2 below is never used in this mode, even for halts before planning starts.
1. **Folder+id dispatch** (`{spec_folder}` and `{story_id}` are set): the write-back always lands at the id-keyed story spec. The `{{ config.implementation_artifacts }}` fallback in step 2 below is never used in this mode, even for halts before planning starts.
- If `{spec_file}` is still empty, resolve it now:
- **Entry not resolved** (`stories.yaml` is missing/unparseable, or `{story_id}` has no matching entry): use the fixed slug segment `unresolved`: `{spec_file}` = `{spec_folder}/stories/{story_id}-unresolved.md`.
- **Ambiguous on-disk match** (the halt is `ambiguous story file match` — more than one file already matches `{spec_folder}/stories/{story_id}-*.md`): use the fixed slug segment `ambiguous` instead of deriving from the title, so the write-back neither creates a third title-derived candidate nor risks silently landing on one of the existing ambiguous files: `{spec_file}` = `{spec_folder}/stories/{story_id}-ambiguous.md`.
@@ -29,7 +29,7 @@ To HALT with a final status and optional blocking condition:
```
2. **Otherwise:**
- If `{spec_file}` is known and exists, update `status` in frontmatter and append missing result details under `## Auto Run Result`.
- If `{spec_file}` is unknown or missing, create `{{.implementation_artifacts}}/bmad-build-auto-result-<slug-or-timestamp>.md` with:
- If `{spec_file}` is unknown or missing, create `{{ config.implementation_artifacts }}/bmad-build-auto-result-<slug-or-timestamp>.md` with:
```markdown
---
status: <final status>
@@ -46,7 +46,7 @@ To HALT with a final status and optional blocking condition:
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
{workflow.on_complete}
{{ workflow.on_complete }}
## Subagents
@@ -80,19 +80,19 @@ A full specification is "Ready for Development" when:
Execute each of these steps in order before proceeding (`_None._` means skip):
{workflow.activation_steps_prepend}
{{ workflow.activation_steps_prepend }}
### Step 2: Load Persistent Facts
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):
{workflow.persistent_facts}
{{ workflow.persistent_facts }}
### Step 3: Execute Append Steps
Execute each of these steps in order (`_None._` means skip):
{workflow.activation_steps_append}
{{ workflow.activation_steps_append }}
Activation is complete after all activation steps have run.
@@ -102,4 +102,4 @@ Follow the step files in order. Read one step fully, execute it, then load the n
## First Workflow Step
Read fully and follow: `[[bmad-snapshot:step-01-clarify-and-route.md]]`.
Read fully and follow: `{{ rendered("step-01-clarify-and-route.md") }}`.
+16 -16
View File
@@ -17,9 +17,9 @@ Before listing artifacts, resolve existing workflow state in this order. Skip th
1. Explicit argument
Did the user pass a specific file path, spec name, or clear instruction this message?
- If the user explicitly supplied a spec folder and a story id, with no specific spec file path, set `spec_folder` and `story_id`. Read `{spec_folder}/stories.yaml`; if it is missing or fails to parse, HALT rather than falling back to `{{.implementation_artifacts}}`. Find the one entry whose string `id` exactly equals `story_id`; if none exists, HALT rather than falling back. Use that entry's `title` and `description` as the starting intent.
- If the user explicitly supplied a spec folder and a story id, with no specific spec file path, set `spec_folder` and `story_id`. Read `{spec_folder}/stories.yaml`; if it is missing or fails to parse, HALT rather than falling back to `{{ config.implementation_artifacts }}`. Find the one entry whose string `id` exactly equals `story_id`; if none exists, HALT rather than falling back. Use that entry's `title` and `description` as the starting intent.
- Look for files matching `{spec_folder}/stories/{story_id}-*.md`. More than one match → HALT rather than choosing one. Exactly one match → set `spec_file` to that path and process it exactly as if the user had supplied that specific file path, including **Story-key resolution** and the existing status route below. No matches → derive a valid kebab-case slug from the entry's `title` (and `description` if needed), then set `spec_file` = `{spec_folder}/stories/{story_id}-{slug}.md` and proceed to INSTRUCTIONS.
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file`. Before exiting, run **Story-key resolution** (below). Then **EARLY EXIT** to the appropriate step: `draft``[[bmad-snapshot:step-02-plan.md]]`, `ready-for-dev`/`in-progress``[[bmad-snapshot:step-03-implement.md]]` (or `[[bmad-snapshot:step-oneshot.md]]` when `route` is `oneshot`), `in-review``[[bmad-snapshot:step-04-review.md]]`. For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume.
- If it points to a file that matches the spec template (has `status` frontmatter with a recognized value: draft, ready-for-dev, in-progress, in-review, or done) → set `spec_file`. Before exiting, run **Story-key resolution** (below). Then **EARLY EXIT** to the appropriate step: `draft``{{ rendered("step-02-plan.md") }}`, `ready-for-dev`/`in-progress``{{ rendered("step-03-implement.md") }}` (or `{{ rendered("step-oneshot.md") }}` when `route` is `oneshot`), `in-review``{{ rendered("step-04-review.md") }}`. For `done`, ingest as context and proceed to INSTRUCTIONS — do not resume.
- Anything else (intent files, external docs, plans, descriptions) → ingest it as starting intent and proceed to INSTRUCTIONS. Do not attempt to infer a workflow state from it.
2. Recent conversation
@@ -27,12 +27,12 @@ Before listing artifacts, resolve existing workflow state in this order. Skip th
Use the same routing as above.
3. Otherwise — scan artifacts and ask
- Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{{.implementation_artifacts}}`? → List them and HALT. Give the user a choice:
- Active specs (`draft`, `ready-for-dev`, `in-progress`, `in-review`) in `{{ config.implementation_artifacts }}`? → List them and HALT. Give the user a choice:
- Resume one of the listed specs
- **New** — start new work
If `draft` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`[[bmad-snapshot:step-02-plan.md]]` (resume planning from the draft)
If `ready-for-dev` or `in-progress` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`[[bmad-snapshot:step-03-implement.md]]` (or `[[bmad-snapshot:step-oneshot.md]]` when `route` is `oneshot`)
If `in-review` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`[[bmad-snapshot:step-04-review.md]]`
If `draft` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`{{ rendered("step-02-plan.md") }}` (resume planning from the draft)
If `ready-for-dev` or `in-progress` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`{{ rendered("step-03-implement.md") }}` (or `{{ rendered("step-oneshot.md") }}` when `route` is `oneshot`)
If `in-review` selected: Set `spec_file`. Run **Story-key resolution** (below). **EARLY EXIT**`{{ rendered("step-04-review.md") }}`
If the user chooses **New**: proceed to INSTRUCTIONS
- Unformatted spec or intent file lacking `status` frontmatter? → Suggest treating its contents as the starting intent. Do NOT attempt to infer a state and resume it.
@@ -40,12 +40,12 @@ Before listing artifacts, resolve existing workflow state in this order. Skip th
This runs on ALL paths (early-exit and INSTRUCTIONS) whenever `spec_file` is set. Determine whether the spec is an epic story — use the spec's filename, frontmatter, and any loaded epics file to identify `epic_num` and `story_num`. If the spec is not an epic story, skip silently and leave `story_key` unset.
If the spec is an epic story and `{{.implementation_artifacts}}/sprint-status.yaml` exists: find the `development_status` key matching `{epic_num}-{story_num}` by exact numeric equality on the first two segments (so `1-1` never collides with `1-10`). Exactly one match → set `story_key` to that full key. Zero or multiple matches → leave `story_key` unset (warn on multiple).
If the spec is an epic story and `{{ config.implementation_artifacts }}/sprint-status.yaml` exists: find the `development_status` key matching `{epic_num}-{story_num}` by exact numeric equality on the first two segments (so `1-1` never collides with `1-10`). Exactly one match → set `story_key` to that full key. Zero or multiple matches → leave `story_key` unset (warn on multiple).
## INSTRUCTIONS
1. Load context.
- List files in `{{.planning_artifacts}}` and `{{.implementation_artifacts}}`.
- List files in `{{ config.planning_artifacts }}` and `{{ config.implementation_artifacts }}`.
- If you find an unformatted spec or intent file, ingest its contents to form your understanding of the intent.
- **Determine context strategy.** Using the intent and the artifact listing, infer whether the current work is a story from an epic. Do not rely on filename patterns or regex — reason about the intent, the listing, and any epics file content together.
@@ -53,17 +53,17 @@ If the spec is an epic story and `{{.implementation_artifacts}}/sprint-status.ya
1. Identify the epic number `{epic_num}` and (if present) the story number `{story_num}`. If you can't identify an epic number, use path B.
2. **Check for a valid cached epic context.** Look for `{{.implementation_artifacts}}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{{.planning_artifacts}}` is newer.
2. **Check for a valid cached epic context.** Look for `{{ config.implementation_artifacts }}/epic-<N>-context.md` (where `<N>` is the epic number). A file is **valid** when it exists, is non-empty, starts with `# Epic <N> Context:` (with the correct epic number), and no file in `{{ config.planning_artifacts }}` is newer.
- **If valid:** load it as the primary planning context. Do not load raw planning docs (PRD, architecture, UX, etc.). Skip to step 5.
- **If missing, empty, or invalid:** continue to step 3.
3. **Compile epic context.** Produce `{{.implementation_artifacts}}/epic-<N>-context.md` by following `[[bmad-snapshot:compile-epic-context.md]]`, in order of preference:
- **Preferred — subagent:** spawn a subagent synchronously (wait for it to return in this turn) with `[[bmad-snapshot:compile-epic-context.md]]` as its prompt. Pass it the epic number, the epics file path, the `{{.planning_artifacts}}` directory, and the output path `{{.implementation_artifacts}}/epic-<N>-context.md`.
- **Fallback — inline** (for runtimes without subagent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn subagents, or the spawn fails/times out, read `[[bmad-snapshot:compile-epic-context.md]]` yourself and follow its instructions to produce the same output file.
3. **Compile epic context.** Produce `{{ config.implementation_artifacts }}/epic-<N>-context.md` by following `{{ rendered("compile-epic-context.md") }}`, in order of preference:
- **Preferred — subagent:** spawn a subagent synchronously (wait for it to return in this turn) with `{{ rendered("compile-epic-context.md") }}` as its prompt. Pass it the epic number, the epics file path, the `{{ config.planning_artifacts }}` directory, and the output path `{{ config.implementation_artifacts }}/epic-<N>-context.md`.
- **Fallback — inline** (for runtimes without subagent support, e.g. Copilot, Codex, local Ollama, older Claude): if your runtime cannot spawn subagents, or the spawn fails/times out, read `{{ rendered("compile-epic-context.md") }}` yourself and follow its instructions to produce the same output file.
4. **Verify.** After compilation, verify the output file exists, is non-empty, and starts with `# Epic <N> Context:`. If valid, load it. If verification fails, HALT and report the failure.
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{{.implementation_artifacts}}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it.
5. **Previous story continuity.** Regardless of which context source succeeded above, scan `{{ config.implementation_artifacts }}` for specs from the same epic with `status: done` and a lower story number. Load the most recent one (highest story number below current). Extract its **Code Map**, **Design Notes**, **Spec Change Log**, and **task list** as continuity context for step-02 planning. If no `done` spec is found but an `in-review` spec exists for the same epic with a lower story number, note it to the user and ask whether to load it.
6. **Resolve `{story_key}`.** If not already set by an earlier early-exit path, run **Story-key resolution** (above) now.
@@ -83,7 +83,7 @@ If the spec is an epic story and `{{.implementation_artifacts}}/sprint-status.ya
- 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 `{{.implementation_artifacts}}/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 `{{ config.implementation_artifacts }}/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_spec: none
summary: <one sentence naming the deferred goal>
@@ -92,8 +92,8 @@ If the spec is an epic story and `{{.implementation_artifacts}}/sprint-status.ya
- If the user chooses **Keep all goals**: Proceed as-is.
5. Set the spec file.
If the explicit spec-folder-plus-story-id pair had no matching story file, keep the colocated `spec_file` selected above. Otherwise, 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 `{{.implementation_artifacts}}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `[[bmad-snapshot:step-02-plan.md]]`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{{.implementation_artifacts}}/spec-{slug}.md`.
If the explicit spec-folder-plus-story-id pair had no matching story file, keep the colocated `spec_file` selected above. Otherwise, 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.implementation_artifacts }}/spec-{slug}.md` already exists: if its status is `draft`, treat it as the same work and resume it (set `spec_file` to that path, **EARLY EXIT** → `{{ rendered("step-02-plan.md") }}`); otherwise append `-2`, `-3`, etc. Set `spec_file` = `{{ config.implementation_artifacts }}/spec-{slug}.md`.
## NEXT
Read fully and follow `[[bmad-snapshot:step-02-plan.md]]`
Read fully and follow `{{ rendered("step-02-plan.md") }}`
+6 -6
View File
@@ -11,21 +11,21 @@
2. Investigate the codebase. When you can, send deep searches to subagents and wait for them in this turn. Tell them to return short summaries only, so this session does not fill up with their notes. Keep only what the work needs: the specific files, symbols or lines, what to reuse, and what not to change. Write that into the Code Map. Do not retell the investigation when implementation starts — the spec already has it.
Do not ask the human during investigation. When something is unclear, look in the repository, planning artifacts, or history first. Keep looking until you know, or until those sources have nothing more to say. Leave any remaining choice for the next step.
3. {workflow.route_selection}
3. {{ workflow.route_selection }}
Intent gaps and irreversible steps (migrations, data mutation, external side effects) always take the full path below.
For oneshot with intent resolved: read `[[bmad-snapshot:spec-template.md]]` fully and write `{spec_file}`.
For oneshot with intent resolved: read `{{ rendered("spec-template.md") }}` fully and write `{spec_file}`.
Set `route: 'oneshot'` and `status: 'in-progress'`, resolving `date` to the current system date.
If `preserved_intent` is non-empty, use it as the frozen block.
**EARLY EXIT**`[[bmad-snapshot:step-oneshot.md]]`.
**EARLY EXIT**`{{ rendered("step-oneshot.md") }}`.
For full, set `route: 'full'` and continue.
4. Read `[[bmad-snapshot:spec-template.md]]` fully. Fill it out from the intent and investigation, resolving the template's `date` field to the current system date. Put the investigation into `## Code Map`: paths, symbols or lines, what to reuse, and what not to change. Implementation should work from the spec without being told the investigation again. If there are intent gaps, add a `## Open Questions` section with one entry per gap: the choice, the options, and what each option means. Never write an intent gap into the frozen block as an assumption. If `preserved_intent` is non-empty, replace the `<frozen-after-approval>` block with it before writing. Write the result to `{spec_file}`.
4. Read `{{ rendered("spec-template.md") }}` fully. Fill it out from the intent and investigation, resolving the template's `date` field to the current system date. Put the investigation into `## Code Map`: paths, symbols or lines, what to reuse, and what not to change. Implementation should work from the spec without being told the investigation again. If there are intent gaps, add a `## Open Questions` section with one entry per gap: the choice, the options, and what each option means. Never write an intent gap into the frozen block as an assumption. If `preserved_intent` is non-empty, replace the `<frozen-after-approval>` block with it before writing. Write the result to `{spec_file}`.
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 spec; 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 spec 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 `{{.implementation_artifacts}}/deferred-work.md` using the format below. Do not modify existing entries or look for duplicates. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec 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 `{{ config.implementation_artifacts }}/deferred-work.md` using the format below. Do not modify existing entries or look for duplicates. Rewrite the current spec to cover only the main goal — do not surgically carve sections out; regenerate the spec for the narrowed scope.
- **Keep full spec** — accept the risks.
```markdown
- source_spec: `{spec_file}`
@@ -60,4 +60,4 @@ Before acting on approval, re-read `{spec_file}` from disk. If it is missing, HA
## NEXT
Read fully and follow `[[bmad-snapshot:step-03-implement.md]]`
Read fully and follow `{{ rendered("step-03-implement.md") }}`
+3 -3
View File
@@ -23,11 +23,11 @@ Capture `baseline_commit` (current HEAD, or `NO_VCS` if version control is unava
Change `{spec_file}` status to `in-progress` in the frontmatter before starting implementation.
If `{story_key}` is not empty and `{{.implementation_artifacts}}/sprint-status.yaml` exists, read `[[bmad-snapshot:sync-sprint-status.md]]` with `{target_status}` = `in-progress`.
If `{story_key}` is not empty and `{{ config.implementation_artifacts }}/sprint-status.yaml` exists, read `{{ rendered("sync-sprint-status.md") }}` with `{target_status}` = `in-progress`.
Execute the implementation handoff below: substitute the runtime placeholders (e.g. `{spec_file}`) into it, then follow it verbatim.
{workflow.implementation_handoff}
{{ workflow.implementation_handoff }}
Do not add goal restatements, file lists, ownership boundaries, investigation detail, acceptance criteria, or CLAUDE.md/house-style rules to the dispatch — the spec is the subagent's sole source of truth, and that material already lives in it (investigation findings in its Code Map, the rest in the spec body). One line of sanctioned hedging belongs in the spec at planning time, not in the dispatch. If no subagents are available, implement directly from the spec. If the platform allows, keep the subagent available for re-engagement after it returns — step-04 may send it review fixes.
@@ -47,4 +47,4 @@ If `{spec_file}`'s `<frozen-after-approval>` block contains an I/O & Edge-Case M
## NEXT
Read fully and follow `[[bmad-snapshot:step-04-review.md]]`
Read fully and follow `{{ rendered("step-04-review.md") }}`
+6 -6
View File
@@ -21,9 +21,9 @@ Writing `{diff_file}` is the only change this section makes. Do NOT `git add` an
Announce skipped layers first, then launch every active layer before handling any layer's result. Try running all active layers simultaneously: substitute the runtime placeholders (`{diff_file}`, `{claims_file}`) into each layer's instruction. `{diff_file}` is a path: substitute the absolute path and let the layer read the file — a launch prompt never carries diff text. When an instruction launches a reviewer subagent, launch that child with the prompt text after placeholder substitution; do not load the reviewer instruction file yourself. For any other customized instruction, execute it as written. Parallel means several blocking calls awaited together in this turn — never backgrounded or detached, never ending the turn to await results. When running layers as subagents, spawn every reviewer before reading or reacting to any of their output; begin collection and triage only once all are launched.
{workflow.review_layers}
{{ workflow.review_layers }}
If a layer's instruction requires subagents and none are available, for each such layer write under `{{.implementation_artifacts}}` that layer's child prompt 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 human to run each in a separate session (ideally a different LLM) and paste back the findings.
If a layer's instruction requires subagents and none are available, for each such layer write under `{{ config.implementation_artifacts }}` that layer's child prompt 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 human to run each in a separate session (ideally a different LLM) and paste back the findings.
### Classify
@@ -59,8 +59,8 @@ If a layer's instruction requires subagents and none are available, for each suc
- **defer** — pre-existing issue not caused by this story; or an entry whose members are all `maybe-false` and the claim, if true, would be `medium` or `high` — record that severity marked unverified, plus what would settle it (if it would only be `low`, reject it with the same note); or any entry whose fix edits agent-context files (CLAUDE.md, AGENTS.md, rules, etc).
4. Process entries in cascading order. If intent_gap or bad_spec entries exist, they trigger a loopback — lower entries are moot since code will be re-derived. If neither exists, process patch and defer normally. Before each loopback, read `{spec_file}` frontmatter `review_loop_iteration` (missing means `0`), increment it by 1, and write it back. If it exceeds 5, HALT and escalate to the human.
- **intent_gap** — Root cause is inside `<frozen-after-approval>`. Revert code changes. Loop back to the human to resolve. Once resolved, read fully and follow `[[bmad-snapshot:step-02-plan.md]]` to re-run steps 24.
- **bad_spec** — Root cause is outside `<frozen-after-approval>`. Before reverting code: extract KEEP instructions for positive preservation (what worked well and must survive re-derivation). Revert code changes. Read the `## Spec Change Log` in `{spec_file}` and strictly respect all logged constraints when amending the non-frozen sections that contain the root cause. Append a new change-log entry recording: the triggering finding, what was amended, the known-bad state avoided, and the KEEP instructions. Read fully and follow `[[bmad-snapshot:step-03-implement.md]]` to re-derive the code, then this step will run again.
- **intent_gap** — Root cause is inside `<frozen-after-approval>`. Revert code changes. Loop back to the human to resolve. Once resolved, read fully and follow `{{ rendered("step-02-plan.md") }}` to re-run steps 24.
- **bad_spec** — Root cause is outside `<frozen-after-approval>`. Before reverting code: extract KEEP instructions for positive preservation (what worked well and must survive re-derivation). Revert code changes. Read the `## Spec Change Log` in `{spec_file}` and strictly respect all logged constraints when amending the non-frozen sections that contain the root cause. Append a new change-log entry recording: the triggering finding, what was amended, the known-bad state avoided, and the KEEP instructions. Read fully and follow `{{ rendered("step-03-implement.md") }}` to re-derive the code, then this step will run again.
- **patch** — Auto-fix. These are the only findings that survive loopbacks. Re-engage the step-03 implementation subagent — the same one, addressed by the name or id its launch returned; a fresh launch is not re-engagement. Send it one message, exactly this, with the findings filled in:
```text
@@ -72,7 +72,7 @@ If a layer's instruction requires subagents and none are available, for each suc
```
If it cannot be continued, apply the patches yourself. Then re-run the checks in `{spec_file}`'s `## Verification` section, if present; 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 `{{.implementation_artifacts}}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates.
- **defer** — Append one new entry to `{{ config.implementation_artifacts }}/deferred-work.md` using this format. Do not modify existing entries or look for duplicates.
```markdown
- source_spec: `{spec_file}`
summary: <one sentence>
@@ -81,4 +81,4 @@ If a layer's instruction requires subagents and none are available, for each suc
## NEXT
Read fully and follow `[[bmad-snapshot:step-05-present.md]]`
Read fully and follow `{{ rendered("step-05-present.md") }}`
+3 -3
View File
@@ -13,13 +13,13 @@
Change `{spec_file}` status to `done` in the frontmatter.
If `{story_key}` is not empty and `{{.implementation_artifacts}}/sprint-status.yaml` exists, read `[[bmad-snapshot:sync-sprint-status.md]]` with `{target_status}` = `review`.
If `{story_key}` is not empty and `{{ config.implementation_artifacts }}/sprint-status.yaml` exists, read `{{ rendered("sync-sprint-status.md") }}` with `{target_status}` = `review`.
### Commit and Complete
If version control is available and the tree is dirty, create a local commit with a conventional message derived from the spec title.
{workflow.open_spec}
{{ workflow.open_spec }}
### Display Summary
@@ -39,4 +39,4 @@ Workflow complete.
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
{workflow.on_complete}
{{ workflow.on_complete }}
+8 -8
View File
@@ -13,7 +13,7 @@ You reach this step from step 2, or from step 1 when resuming a spec whose `rout
### Implement
If `{story_key}` is not empty and `{{.implementation_artifacts}}/sprint-status.yaml` exists, read `[[bmad-snapshot:sync-sprint-status.md]]` with `{target_status}` = `in-progress`.
If `{story_key}` is not empty and `{{ config.implementation_artifacts }}/sprint-status.yaml` exists, read `{{ rendered("sync-sprint-status.md") }}` with `{target_status}` = `in-progress`.
Build the change from `{spec_file}`. The Intent section is what you implement. As you work, add notes to `## Implementation Notes`: decisions you made, files you changed, surprises.
@@ -23,15 +23,15 @@ Build the change from `{spec_file}`. The Intent section is what you implement. A
- you need to do something you cannot undo
- the remaining work is substantially larger than anticipated
Write what triggered the stop in `## Implementation Notes`. Then update `{spec_file}`: add back `## Code Map` (filled in from what you learned while implementing) and `## Open Questions` (one question per gap), set `route: 'full'` and `status: 'draft'`. Go back to `[[bmad-snapshot:step-02-plan.md]]` step 6.
Write what triggered the stop in `## Implementation Notes`. Then update `{spec_file}`: add back `## Code Map` (filled in from what you learned while implementing) and `## Open Questions` (one question per gap), set `route: 'full'` and `status: 'draft'`. Go back to `{{ rendered("step-02-plan.md") }}` step 6.
### Review
Say which review layers you are skipping, then start every active layer before reading any results. Run them at the same time when you can. Fill in runtime placeholders first. When a layer tells you to launch a reviewer subagent, launch it with that prompt text. Do not read the reviewer's instruction file yourself. For any other customized instruction, do what it says:
{workflow.oneshot_review_layers}
{{ workflow.oneshot_review_layers }}
If a layer needs subagents and you cannot launch them, write the full prompt for each layer under `{{.implementation_artifacts}}` (with placeholders filled in, not just file paths). Stop and ask the user to run each prompt in a separate session and paste back the findings.
If a layer needs subagents and you cannot launch them, write the full prompt for each layer under `{{ config.implementation_artifacts }}` (with placeholders filled in, not just file paths). Stop and ask the user to run each prompt in a separate session and paste back the findings.
### Classify
@@ -58,7 +58,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 `{{.implementation_artifacts}}/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 `{{ config.implementation_artifacts }}/deferred-work.md`:
```markdown
- source_spec: `{spec_file}`
@@ -75,7 +75,7 @@ Update `{spec_file}`:
1. Set `status: 'done'` in the frontmatter.
2. If review found anything, add `## Review Triage Log` with one line per finding: verdict and evidence. For `false`, the disproof. For `maybe-false`, what would settle it. For rejected `low`, why it was not worth fixing.
If `{story_key}` is not empty and `{{.implementation_artifacts}}/sprint-status.yaml` exists, read `[[bmad-snapshot:sync-sprint-status.md]]` with `{target_status}` = `review`.
If `{story_key}` is not empty and `{{ config.implementation_artifacts }}/sprint-status.yaml` exists, read `{{ rendered("sync-sprint-status.md") }}` with `{target_status}` = `review`.
### Commit
@@ -83,7 +83,7 @@ If git is available and there are uncommitted changes, commit with a conventiona
### Present
{workflow.open_spec}
{{ workflow.open_spec }}
Give the user a short summary — one or two sentences:
@@ -103,4 +103,4 @@ Workflow complete.
If anything appears below, do it before exiting. Otherwise exit.
{workflow.on_complete}
{{ workflow.on_complete }}
+1 -1
View File
@@ -1,4 +1,4 @@
Set `development_status[{story_key}]` to `{target_status}` in `{{.implementation_artifacts}}/sprint-status.yaml`.
Set `development_status[{story_key}]` to `{target_status}` in `{{ config.implementation_artifacts }}/sprint-status.yaml`.
If `{story_key}` is missing, warn once and stop.
If the story is already at `{target_status}` or later, stop.
When `{target_status}` is `in-progress`, set parent epic (e.g. `3-2-foo``epic-3`) from `backlog` to `in-progress` if present.
+4 -4
View File
@@ -40,19 +40,19 @@ A specification should target a **single user-facing goal** within **9001600
Execute each of these steps in order before proceeding (`_None._` means skip):
{workflow.activation_steps_prepend}
{{ workflow.activation_steps_prepend }}
### Step 2: Load Persistent Facts
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):
{workflow.persistent_facts}
{{ workflow.persistent_facts }}
### Step 3: Execute Append Steps
Execute each of these steps in order (`_None._` means skip):
{workflow.activation_steps_append}
{{ workflow.activation_steps_append }}
## WORKFLOW ARCHITECTURE
@@ -81,4 +81,4 @@ This uses **step-file architecture** for disciplined execution:
## FIRST STEP
Read fully and follow: `[[bmad-snapshot:step-01-clarify-and-route.md]]` to begin the workflow.
Read fully and follow: `{{ rendered("step-01-clarify-and-route.md") }}` to begin the workflow.
@@ -34,7 +34,7 @@ story_key: '' # set at runtime when discovered from sprint status
Do the last few messages reveal what the user wants to be reviewed? Look for spec paths, commit refs, branches, PRs, or descriptions of a change. Apply the same diff-mode keyword scan and routing as Tier 1.
**Tier 3 — Sprint tracking.**
Look for a sprint status file (`*sprint-status*`) in `{{.implementation_artifacts}}` or `{{.planning_artifacts}}`. If found, scan for stories with status `review`:
Look for a sprint status file (`*sprint-status*`) in `{{ config.implementation_artifacts }}` or `{{ config.planning_artifacts }}`. If found, scan for stories with status `review`:
- **Exactly one `review` story:** Set `story_key` to the story's key (e.g., `1-2-user-auth`). HALT and give the user a choice:
- **Review this story** — review the detected story `<story-id>` (status `review`).
- **Choose another target** — pick a different review target.
@@ -90,4 +90,4 @@ Present a summary before proceeding: diff stats (files changed, lines added/remo
## NEXT
Read fully and follow `[[bmad-snapshot:step-02-review.md]]`
Read fully and follow `{{ rendered("step-02-review.md") }}`
+3 -3
View File
@@ -17,9 +17,9 @@ failed_layers: '' # set at runtime: comma-separated list of layers that failed o
2. Announce skipped layers first, then launch every active layer before handling any layer's result. Try running all active layers simultaneously: substitute the runtime placeholders (`{diff_file}`, `{claims_file}`, `{spec_file}`) into each layer's instruction. `{diff_file}` is a path: substitute the absolute path and let the layer read the file — a launch prompt never carries diff text, and the child's working directory is not yours. When an instruction launches a reviewer subagent, launch that child with the prompt text after placeholder substitution; do not load the reviewer instruction file yourself. For any other customized instruction, execute it as written. When running layers as subagents, spawn every reviewer before reading or reacting to any of their output; begin collection only once all are launched.
{workflow.review_layers}
{{ workflow.review_layers }}
3. If a layer's instruction requires subagents and none are available, for each such layer write under `{{.implementation_artifacts}}` that layer's child prompt 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 layers' findings and resume from this point.
3. If a layer's instruction requires subagents and none are available, for each such layer write under `{{ config.implementation_artifacts }}` that layer's child prompt 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 layers' findings and resume from this point.
4. **Layer failure handling**: If any layer fails, times out, or returns empty results, append the layer's `name` to `failed_layers` (comma-separated) and proceed with findings from the remaining layers.
@@ -27,4 +27,4 @@ failed_layers: '' # set at runtime: comma-separated list of layers that failed o
## NEXT
Read fully and follow `[[bmad-snapshot:step-03-triage.md]]`
Read fully and follow `{{ rendered("step-03-triage.md") }}`
+1 -1
View File
@@ -47,4 +47,4 @@
## NEXT
Read fully and follow `[[bmad-snapshot:step-04-present.md]]`
Read fully and follow `{{ rendered("step-04-present.md") }}`
+3 -3
View File
@@ -1,6 +1,6 @@
---
deferred_work_file: '{{.implementation_artifacts}}/deferred-work.md'
sprint_status: '{{.implementation_artifacts}}/sprint-status.yaml'
deferred_work_file: '{{ config.implementation_artifacts }}/deferred-work.md'
sprint_status: '{{ config.implementation_artifacts }}/sprint-status.yaml'
---
# Step 4: Present and Act
@@ -131,4 +131,4 @@ Present the user with follow-up options:
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
{workflow.on_complete}
{{ workflow.on_complete }}
+4 -4
View File
@@ -19,19 +19,19 @@ If you need an explicit user instruction to run them, ask once now for the whole
Execute each of these steps in order before proceeding (`_None._` means skip):
{workflow.activation_steps_prepend}
{{ workflow.activation_steps_prepend }}
### Step 2: Load Persistent Facts
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):
{workflow.persistent_facts}
{{ workflow.persistent_facts }}
### Step 3: Execute Append Steps
Execute each of these steps in order (`_None._` means skip):
{workflow.activation_steps_append}
{{ workflow.activation_steps_append }}
Activation is complete after all activation steps have run.
@@ -62,4 +62,4 @@ This uses **step-file architecture** for disciplined execution:
## FIRST STEP
Read fully and follow: `[[bmad-snapshot:step-01-gather-context.md]]` to begin the workflow.
Read fully and follow: `{{ rendered("step-01-gather-context.md") }}` to begin the workflow.
@@ -20,7 +20,7 @@ Compile fix-now findings and process lessons into specific, owned action items.
## Previous-retro follow-through
When a prior retro exists, check whether the action items it committed to were completed. Read `action_items` in `{{.implementation_artifacts}}/sprint-status.yaml` and, for every entry belonging to an earlier epic that is not already `done`, record one line in the retrospective document's Previous-retro follow-through section:
When a prior retro exists, check whether the action items it committed to were completed. Read `action_items` in `{{ config.implementation_artifacts }}/sprint-status.yaml` and, for every entry belonging to an earlier epic that is not already `done`, record one line in the retrospective document's Previous-retro follow-through section:
- **How to address the item** — its `id`, exactly as the file spells it. Legacy entries written before ids existed have none; for those, record the item's `epic` (the integer in the file) plus its exact `action` text, character for character. One or the other is what Phase 5 needs to name the item at all.
- **Whether it landed** — with the source that shows it: the commit, the file and line, the test. An item you cannot point at is "no evidence found", not "not done" — the reader must be able to tell a checked item from an unchecked one.
@@ -2,7 +2,7 @@
Phase 2. An epic is many coding sessions, each validated in isolation; the defects that matter are the ones no single session — and no single diff hunk — could see. Nine sessions each added three hundred lines and none ever saw the 3,000-line class they collectively built. These views are properties of the *whole* change, derived across the full diff range from Phase 1.
Prefer deterministic derivation: a script that measures the codebase is evidence; a model's impression is not. Where you compute a view inline instead of by script, record the narrowed scope. Every observation that becomes a finding carries a source reference — the file, the symbol, the commits. `[[bmad-snapshot:references/evidence-gathering.md]]` is authoritative for what every `git_evidence.py` key means, including the commit-level `is_merge` and `stories` (every story id a subject names, so a commit spanning two counts for both) — read it there before deriving anything from the numbers.
Prefer deterministic derivation: a script that measures the codebase is evidence; a model's impression is not. Where you compute a view inline instead of by script, record the narrowed scope. Every observation that becomes a finding carries a source reference — the file, the symbol, the commits. `{{ rendered("references/evidence-gathering.md") }}` is authoritative for what every `git_evidence.py` key means, including the commit-level `is_merge` and `stories` (every story id a subject names, so a commit spanning two counts for both) — read it there before deriving anything from the numbers.
## The catalog
@@ -6,10 +6,10 @@ Phase 1 of the retrospective. Enumerate what the completed epic produced, so eve
Collect what the epic produced and note the source path or range of each:
- **Epic spec** — the epic file under `{{.planning_artifacts}}`, including any declared acceptance criteria. If the spec declares how the epic will be judged, that governs Phase 4; if not, note that the verdict will be profiled from the diff.
- **Story files** — the story specs implemented under this epic (`{{.implementation_artifacts}}`), each carrying its intent and context. These mark the boundaries between coding sessions.
- **Epic spec** — the epic file under `{{ config.planning_artifacts }}`, including any declared acceptance criteria. If the spec declares how the epic will be judged, that governs Phase 4; if not, note that the verdict will be profiled from the diff.
- **Story files** — the story specs implemented under this epic (`{{ config.implementation_artifacts }}`), each carrying its intent and context. These mark the boundaries between coding sessions.
- **Diff range and commits** — the full set of changes the epic introduced. Establish the range from the first and last story commits (or ask the user for it). The range must *include* the first story commit: `A..B` excludes `A`, so use the parent of the first commit as the left endpoint — `<first-commit>^..<last-commit>` — or the whole first story disappears from the diff, the commit attribution, and the verdict evidence. Then run `uv run --no-cache {skill-root}/scripts/git_evidence.py --repo {project-root} --range <range> --stories <story-ids>` to get, as JSON, the per-story commit attribution and the per-file change volume — added / deleted / net across the range — that Phase 2 reads. Record the range explicitly; Phase 2's aggregate views and the `bmad-review` pass both read it. When the range cannot be established, say so and narrow the scope rather than guessing. Read the output keys precisely: each commit carries `is_merge` and `stories`*every* id its subject names, so a commit spanning two stories counts for both. `files` sums non-merge commits only. `merge_files` is each measured merge's diff against its first parent, so it *restates* the churn that merge brought in plus whatever the conflict resolution added — never add it into `files`, and never read it as merge-introduced work on its own. `merges_measured` counts the merges on the range head's first-parent spine; `merge_count` counts every merge in the range, so a gap between the two means merges went unmeasured. `binary_revisions` is unmeasured churn, not zero churn.
- **Sprint status**`{{.implementation_artifacts}}/sprint-status.yaml`, for which stories are `done` and the current retro-key state.
- **Sprint status**`{{ config.implementation_artifacts }}/sprint-status.yaml`, for which stories are `done` and the current retro-key state.
- **Previous retrospective** — the prior epic's retro doc, if one exists, so Phase 4 can check whether last epic's action items landed.
- **Session logs** — conversation or session records for the epic's stories, when available. They are the only record of *why* a session took an unexpected turn — what was tried and abandoned. They are also the evidence most likely to be deleted or expire, so capture references now.
@@ -4,13 +4,13 @@ Phase 5. Finalize the retrospective and update sprint tracking. Two writes: the
## The retrospective document
This document is the run's working artifact: it is created as a skeleton once the epic is fixed and filled as each phase completes, so Phase 5 finalizes rather than writes it from scratch. It lives at `{{.implementation_artifacts}}/epic-{{epic_number}}-retro-{date}.md`, as readable markdown; ensure `{{.implementation_artifacts}}` exists. In stories mode it lives at `{spec-folder}/RETROSPECTIVE.md` instead — a fixed name, so a resumed run finds it — and carries the same frontmatter without `epic`, which the folder already names.
This document is the run's working artifact: it is created as a skeleton once the epic is fixed and filled as each phase completes, so Phase 5 finalizes rather than writes it from scratch. It lives at `{{ config.implementation_artifacts }}/epic-{% raw %}{{epic_number}}{% endraw %}-retro-{date}.md`, as readable markdown; ensure `{{ config.implementation_artifacts }}` exists. In stories mode it lives at `{spec-folder}/RETROSPECTIVE.md` instead — a fixed name, so a resumed run finds it — and carries the same frontmatter without `epic`, which the folder already names.
Open the document with YAML frontmatter a machine can read without parsing the prose — an epic gate or orchestrator keys off `verdict` to decide whether to hold the next epic:
```
---
epic: {{epic_number}}
epic: {% raw %}{{epic_number}}{% endraw +%}
date: {date}
verdict: accepted | accepted-with-open-items | rejected
criteria: declared | profiled
@@ -24,10 +24,10 @@ That holds for a **rejected** epic too: the update below marks the retro key `do
Sections:
- **Epic summary** — which epic, the diff range, stories completed, any stories still unfinished (`pending_stories`) that the user accepted retro-ing over, the evidence inventory (what was available, what was missing). Unfinished stories force the machine acceptance verdict to **rejected** (see `[[bmad-snapshot:references/acceptance-verdict.md]]`).
- **Epic summary** — which epic, the diff range, stories completed, any stories still unfinished (`pending_stories`) that the user accepted retro-ing over, the evidence inventory (what was available, what was missing). Unfinished stories force the machine acceptance verdict to **rejected** (see `{{ rendered("references/acceptance-verdict.md") }}`).
- **Findings** — grouped by aggregate view and by lens, each with its source reference and disposition (fix now / defer / accept). This is the record; do not summarize away the provenance.
- **Behavior verification** — what was exercised end to end and what was observed, or an explicit note that runtime behavior was not exercised.
- **Previous-retro follow-through** — if a prior retro exists, whether its action items landed, with evidence, and the selector Phase 5 would need to act on each (`[[bmad-snapshot:references/acceptance-verdict.md]]` specifies what to record).
- **Previous-retro follow-through** — if a prior retro exists, whether its action items landed, with evidence, and the selector Phase 5 would need to act on each (`{{ rendered("references/acceptance-verdict.md") }}` specifies what to record).
- **Action items** — the routed fix-now items and process lessons, each with an owner. Note which are proposed remediation or spec reconciliations awaiting human application.
- **Acceptance verdict** — accepted / accepted-with-open-items / rejected, whether the criteria were declared or profiled, and the evidence behind the call.
- **Open questions** — what a human answer would materially change, and anything the analyses could not resolve.
@@ -41,17 +41,17 @@ Do not hand-edit `sprint-status.yaml` — its comment blocks and quoting are exa
```
uv run --no-cache {skill-root}/scripts/sprint_status.py update \
--file "{{.implementation_artifacts}}/sprint-status.yaml" \
--epic {{epic_number}} --set-retro-done \
--file "{{ config.implementation_artifacts }}/sprint-status.yaml" \
--epic {% raw %}{{epic_number}}{% endraw %} --set-retro-done \
--add-action '[{"action":"...","owner":"..."}, ...]' \
--ref "{{.implementation_artifacts}}/epic-{{epic_number}}-retro-{date}.md" \
--ref "{{ config.implementation_artifacts }}/epic-{% raw %}{{epic_number}}{% endraw %}-retro-{date}.md" \
--verdict "<accepted | accepted-with-open-items | rejected>" \
--date "{date}"
```
Keep every value quoted. `--date` is parsed as `MM-DD-YYYY HH:MM` and nothing else — unpadded spellings like `1-2-2026 9:05` are accepted and normalized to the padded form, but a value that does not parse is rejected with `ok: false`, `restored: true` and exit 1, before the file is touched, and the whole update is a no-op. So pass `{date}` only if it is already in that form; otherwise reformat it, or omit the flag entirely and let the script stamp the current time itself. That format carries a space, which is why the flag must be quoted: unquoted, `--date 07-28-2026 14:23` splits into two argv words and dies at argparse (`{"ok": false, "error": "argument error: unrecognized arguments: 14:23"}`, exit 2). `--file` and `--ref` are quoted for the same reason — an `{{.implementation_artifacts}}` path containing a space breaks them exactly the same way.
Keep every value quoted. `--date` is parsed as `MM-DD-YYYY HH:MM` and nothing else — unpadded spellings like `1-2-2026 9:05` are accepted and normalized to the padded form, but a value that does not parse is rejected with `ok: false`, `restored: true` and exit 1, before the file is touched, and the whole update is a no-op. So pass `{date}` only if it is already in that form; otherwise reformat it, or omit the flag entirely and let the script stamp the current time itself. That format carries a space, which is why the flag must be quoted: unquoted, `--date 07-28-2026 14:23` splits into two argv words and dies at argparse (`{"ok": false, "error": "argument error: unrecognized arguments: 14:23"}`, exit 2). `--file` and `--ref` are quoted for the same reason — an `{{ config.implementation_artifacts }}` path containing a space breaks them exactly the same way.
It sets `development_status["epic-{{epic_number}}-retrospective"]` to `done`, appends one `action_items` entry per proposed item, and bumps `last_updated`. Each appended item carries `status: open`, a stable `id` (`epic-<N>-retro-item-<n>-<slug>` derived from the action text, or the `id` you supply in the JSON), and a `ref` back to this retro document (from `--ref`, or a per-item `ref` in the JSON) — so an orchestrator can dedupe items across re-runs and dispatch each one to its full, sourced finding. `--verdict` is not written into the file; it is echoed back in the result JSON as a signal for consumers. It accepts exactly the frontmatter vocabulary — `accepted`, `accepted-with-open-items`, `rejected` — and any other spelling is rejected (`ok: false`, `restored: true`, exit 1) before the file is touched. Read the JSON it returns:
It sets `development_status["epic-{% raw %}{{epic_number}}{% endraw %}-retrospective"]` to `done`, appends one `action_items` entry per proposed item, and bumps `last_updated`. Each appended item carries `status: open`, a stable `id` (`epic-<N>-retro-item-<n>-<slug>` derived from the action text, or the `id` you supply in the JSON), and a `ref` back to this retro document (from `--ref`, or a per-item `ref` in the JSON) — so an orchestrator can dedupe items across re-runs and dispatch each one to its full, sourced finding. `--verdict` is not written into the file; it is echoed back in the result JSON as a signal for consumers. It accepts exactly the frontmatter vocabulary — `accepted`, `accepted-with-open-items`, `rejected` — and any other spelling is rejected (`ok: false`, `restored: true`, exit 1) before the file is touched. Read the JSON it returns:
- `ok: true` → report the retro-key transition, `action_items_added`, `action_items_updated`, and the echoed `verdict`.
- `ok: false` → the file was left untouched (`restored: true`); surface the error, do not hand-edit. `restored: false` means the rollback write also failed and the file may be incomplete — warn the user explicitly.
@@ -63,8 +63,8 @@ Moving a *previous* epic's action items off `open` is recorded in the retro docu
```
uv run --no-cache {skill-root}/scripts/sprint_status.py update \
--file "{{.implementation_artifacts}}/sprint-status.yaml" \
--epic {{epic_number}} \
--file "{{ config.implementation_artifacts }}/sprint-status.yaml" \
--epic {% raw %}{{epic_number}}{% endraw %} \
--set-action-status '[{"id":"epic-1-retro-item-1-add-error-handling","status":"done"},{"epic":1,"action":"Exact action text","status":"in-progress"}]'
```
@@ -87,4 +87,4 @@ Report where the document was saved, the verdict, and the action-item count.
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
{workflow.on_complete}
{{ workflow.on_complete }}
+17 -17
View File
@@ -24,19 +24,19 @@ For automation, `-H <epic>` — an explicit epic in headless mode — is the sta
Execute each of these steps in order before proceeding (`_None._` means skip):
{workflow.activation_steps_prepend}
{{ workflow.activation_steps_prepend }}
### Step 2: Load Persistent Facts
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):
{workflow.persistent_facts}
{{ workflow.persistent_facts }}
### Step 3: Execute Append Steps
Execute each of these steps in order (`_None._` means skip):
{workflow.activation_steps_append}
{{ workflow.activation_steps_append }}
Activation is complete after all activation steps have run.
@@ -46,29 +46,29 @@ Activation is complete after all activation steps have run.
|-------|-------|-----|
| epic | invocation argument, or detected from sprint status | which epic to retro |
| spec folder | invocation argument, or found under the spec roots | the stories-mode epic: `SPEC.md`, ordered `stories.yaml`, `stories/<id>-*.md` |
| sprint status | `{{.implementation_artifacts}}/sprint-status.yaml` | epic detection + final status update |
| architecture / prd | `{{.planning_artifacts}}/*architecture*`, `*prd*` | context for judging as-built vs intended |
| previous retro (optional) | `{{.implementation_artifacts}}/**/epic-{{prev}}-retro-*.md` | check whether last epic's actions landed |
| sprint status | `{{ config.implementation_artifacts }}/sprint-status.yaml` | epic detection + final status update |
| architecture / prd | `{{ config.planning_artifacts }}/*architecture*`, `*prd*` | context for judging as-built vs intended |
| previous retro (optional) | `{{ config.implementation_artifacts }}/**/epic-{% raw %}{{prev}}{% endraw %}-retro-*.md` | check whether last epic's actions landed |
| session logs (optional) | conversation/session records for the epic's stories | process lessons; record the gap when absent |
An epic reaches this workflow in one of two shapes, and they are peers. **Sprint mode** reads `sprint-status.yaml`. **Stories mode** reads a spec folder holding `SPEC.md`, an ordered `stories.yaml`, and `stories/<id>-*.md` artifacts — the shape an unattended run leaves behind. Resolve which applies first: a named folder is stories mode whether or not sprint status exists; a named epic number is sprint mode; with neither, use sprint mode when `sprint-status.yaml` exists, and otherwise look for spec folders under `{{.output_folder}}/specs`, `{{.planning_artifacts}}`, and `{{.implementation_artifacts}}`. Ask the user which to retro when there is more than one, and never choose silently; headless, stop and require an explicit folder.
An epic reaches this workflow in one of two shapes, and they are peers. **Sprint mode** reads `sprint-status.yaml`. **Stories mode** reads a spec folder holding `SPEC.md`, an ordered `stories.yaml`, and `stories/<id>-*.md` artifacts — the shape an unattended run leaves behind. Resolve which applies first: a named folder is stories mode whether or not sprint status exists; a named epic number is sprint mode; with neither, use sprint mode when `sprint-status.yaml` exists, and otherwise look for spec folders under `{{ config.output_folder }}/specs`, `{{ config.planning_artifacts }}`, and `{{ config.implementation_artifacts }}`. Ask the user which to retro when there is more than one, and never choose silently; headless, stop and require an explicit folder.
In stories mode, `stories.yaml` in list order is the story list — list order is authoritative, filename sort is not — and each story's `stories/<id>-*.md` frontmatter carries its `status`. `pending_stories` is the ids whose status is not `done`; apply the same completeness gate as below. Then skip to Phase 1: do not read or write sprint status for the rest of the run. The rest of this section is sprint mode.
Determine the epic and its unfinished-story list from `sprint_status.py detect-epic` whenever `{{.implementation_artifacts}}/sprint-status.yaml` is available:
Determine the epic and its unfinished-story list from `sprint_status.py detect-epic` whenever `{{ config.implementation_artifacts }}/sprint-status.yaml` is available:
- **Epic supplied** (including the stable `-H <epic>` orchestrator path): run `uv run --no-cache {skill-root}/scripts/sprint_status.py detect-epic --file {{.implementation_artifacts}}/sprint-status.yaml --epic <N>`. The script scopes `pending_stories` to that number even when auto-detect would have picked a different epic, and even when the epic has no `done` story yet. `story_count` is that same scoped count of the epic's story keys: `0` means the file has no such epic at all — a nonexistent epic returns the same empty `pending_stories` as a finished one, so treat `story_count: 0` as a likely mistyped epic number, confirm with the user, and headless, stop and report rather than proceeding.
- **Epic supplied** (including the stable `-H <epic>` orchestrator path): run `uv run --no-cache {skill-root}/scripts/sprint_status.py detect-epic --file {{ config.implementation_artifacts }}/sprint-status.yaml --epic <N>`. The script scopes `pending_stories` to that number even when auto-detect would have picked a different epic, and even when the epic has no `done` story yet. `story_count` is that same scoped count of the epic's story keys: `0` means the file has no such epic at all — a nonexistent epic returns the same empty `pending_stories` as a finished one, so treat `story_count: 0` as a likely mistyped epic number, confirm with the user, and headless, stop and report rather than proceeding.
- **No epic supplied**: run the same command without `--epic` (returns the highest epic with a `done` story). Confirm the detected epic with the user and let them override; in headless mode accept it and record the assumption. If detection returns none, ask the user — or, headless, stop and report.
If the script exits non-zero it emits `{"ok": false, "error": ...}` instead of a detection — the normal path for a stories-mode project with no `sprint-status.yaml`, and for a file that does not parse: surface that error verbatim — or, if the script produced no JSON at all, whatever it wrote to stderr — and ask the user which epic to retro; headless, stop and report. Without a readable sprint-status file there is no `pending_stories` list; record that the completeness check did not run and continue only if the user (or headless Assumptions trail) accepts proceeding without it.
Then check the epic is actually finished before Phase 1. A successful detect carries `pending_stories` — the selected epic's story keys whose status is not `done`, in file order, scoped to that epic alone (an unfinished story in some *other* epic is out of scope for this retrospective). When the list is non-empty, interactively list those stories and ask whether to retro an unfinished epic: if the user declines, stop and report — do not enter Phase 1; if they accept, record the stories they accepted proceeding over in the document's Epic summary. Headless, proceed and record the same list in the Assumptions section — do not invent a confirmation. Either way the list sits in the document, and Phase 4's machine verdict is **rejected** when any story remained unfinished (see `[[bmad-snapshot:references/acceptance-verdict.md]]`); a human may override interactively.
Then check the epic is actually finished before Phase 1. A successful detect carries `pending_stories` — the selected epic's story keys whose status is not `done`, in file order, scoped to that epic alone (an unfinished story in some *other* epic is out of scope for this retrospective). When the list is non-empty, interactively list those stories and ask whether to retro an unfinished epic: if the user declines, stop and report — do not enter Phase 1; if they accept, record the stories they accepted proceeding over in the document's Epic summary. Headless, proceed and record the same list in the Assumptions section — do not invent a confirmation. Either way the list sits in the document, and Phase 4's machine verdict is **rejected** when any story remained unfinished (see `{{ rendered("references/acceptance-verdict.md") }}`); a human may override interactively.
## Working state and resumption
The retrospective document is the working artifact, not only the final output. Once the epic is fixed, create it as a skeleton (`[[bmad-snapshot:references/retro-document.md]]` names the sections) and write each phase's result into it as you finish — inventory, then findings with sources, then dispositions and verdict. Continuity is re-reading the file.
The retrospective document is the working artifact, not only the final output. Once the epic is fixed, create it as a skeleton (`{{ rendered("references/retro-document.md") }}` names the sections) and write each phase's result into it as you finish — inventory, then findings with sources, then dispositions and verdict. Continuity is re-reading the file.
If a retrospective document for this epic already exists, load it, reconcile its recorded state against the current evidence — the current evidence wins, since commits may have landed and questions may have been answered since — and resume at the first incomplete phase instead of redoing finished ones. In stories mode that document is `{spec-folder}/RETROSPECTIVE.md`, a fixed name so a resumed run finds it; sprint mode keeps its dated `{{.implementation_artifacts}}` filename.
If a retrospective document for this epic already exists, load it, reconcile its recorded state against the current evidence — the current evidence wins, since commits may have landed and questions may have been answered since — and resume at the first incomplete phase instead of redoing finished ones. In stories mode that document is `{spec-folder}/RETROSPECTIVE.md`, a fixed name so a resumed run finds it; sprint mode keeps its dated `{{ config.implementation_artifacts }}` filename.
## Flow
@@ -78,13 +78,13 @@ Before Phase 1, in either mode, interactively invite the user's going-in concern
### Phase 1 — Gather
Enumerate what the epic actually produced and record what is missing. Read fully and follow `[[bmad-snapshot:references/evidence-gathering.md]]` for the inventory checklist, the `git_evidence.py` pre-pass that derives the diff range and per-story commits, and the missing-evidence rule: each later analysis declares what it needs and records a narrowed scope when the evidence is absent, so a reader can always tell "checked and clean" from "never checked."
Enumerate what the epic actually produced and record what is missing. Read fully and follow `{{ rendered("references/evidence-gathering.md") }}` for the inventory checklist, the `git_evidence.py` pre-pass that derives the diff range and per-story commits, and the missing-evidence rule: each later analysis declares what it needs and records a narrowed scope when the evidence is absent, so a reader can always tell "checked and clean" from "never checked."
### Phase 2 — Analyze
Produce findings, each with a source reference, from three angles:
- **Aggregate views** — the defects no single diff hunk shows: architecture delta, duplication map, god-class growth, pattern divergence, spec-to-implementation reconciliation. Read fully and follow `[[bmad-snapshot:references/aggregate-views.md]]` for the catalog and how to derive each (deterministic scripts first).
- **Aggregate views** — the defects no single diff hunk shows: architecture delta, duplication map, god-class growth, pattern divergence, spec-to-implementation reconciliation. Read fully and follow `{{ rendered("references/aggregate-views.md") }}` for the catalog and how to derive each (deterministic scripts first).
- **Diff-scope review** — do not reimplement review. Invoke **`bmad-review`** on the epic's diff for the code lenses (adversarial, edge-case, verification-gap), weighting the boundaries between stories, where no single session ever saw both sides. Fold its findings in. If `bmad-review` is unavailable, run those lenses inline over the diff on a narrowed scope and record the narrowing.
- **Behavior check (when the epic changed runtime behavior)** — exercise the changed flows end to end and record what you observed. Passing tests do not substitute for running the system.
@@ -92,13 +92,13 @@ Consolidate: merge, dedupe, and provenance-link findings. Drop any finding you c
### Phase 3 — Team Discussion (opt-in)
Skip by default; never runs headless. When the user asks to "discuss it as a team," "run party mode," or similar, invoke the skill `bmad-party-mode` seeded with the Phase 2 findings so the installed agents react to real evidence — the god class the diff really grew, the verification gap that is actually there, the wins the evidence confirms. Read fully and follow `[[bmad-snapshot:references/team-discussion.md]]` for how to seed it and keep it grounded. If `bmad-party-mode` is unavailable, run the discussion inline over the Phase 2 findings and record the narrowing. The rule: agents speak only to findings with sources.
Skip by default; never runs headless. When the user asks to "discuss it as a team," "run party mode," or similar, invoke the skill `bmad-party-mode` seeded with the Phase 2 findings so the installed agents react to real evidence — the god class the diff really grew, the verification gap that is actually there, the wins the evidence confirms. Read fully and follow `{{ rendered("references/team-discussion.md") }}` for how to seed it and keep it grounded. If `bmad-party-mode` is unavailable, run the discussion inline over the Phase 2 findings and record the narrowing. The rule: agents speak only to findings with sources.
### Phase 4 — Decide
- **Action items** — compile fix-now findings and process lessons into specific, owned action items. Fixes and spec reconciliations are *proposed here*, not auto-applied; the human decides what to execute.
- **Acceptance verdict** — judge the final state against the epic's declared acceptance criteria (profile it from the diff and stories if none were declared): **accepted**, **accepted-with-open-items**, or **rejected** — one spelling, everywhere a machine reads it. Unfinished stories in `pending_stories` force the machine verdict to **rejected**. A human decision always overrides. An epic that fails its criteria with no human decision is recorded as *not accepted* — never as silently accepted. Read fully and follow `[[bmad-snapshot:references/acceptance-verdict.md]]` for the rubric, the finding-routing dispositions, and the previous-retro follow-through record — the per-item evidence Phase 5's status offer reads.
- **Acceptance verdict** — judge the final state against the epic's declared acceptance criteria (profile it from the diff and stories if none were declared): **accepted**, **accepted-with-open-items**, or **rejected** — one spelling, everywhere a machine reads it. Unfinished stories in `pending_stories` force the machine verdict to **rejected**. A human decision always overrides. An epic that fails its criteria with no human decision is recorded as *not accepted* — never as silently accepted. Read fully and follow `{{ rendered("references/acceptance-verdict.md") }}` for the rubric, the finding-routing dispositions, and the previous-retro follow-through record — the per-item evidence Phase 5's status offer reads.
### Phase 5 — Finalize
Finalize the retrospective document and update sprint status. Read fully and follow `[[bmad-snapshot:references/retro-document.md]]` for the document's sections, the exact `sprint_status.py update` invocation that marks the retro key `done`, appends the action items, and validates the write, and the terminal instruction that ends the run. Where the Phase 4 follow-through has evidence a *previous* epic's action item landed, offer `--set-action-status` and pass only the transitions the user confirms — the evidence justifies proposing a transition, and only the user's confirmation justifies writing it; a headless run records the transitions it would have proposed and does not pass the flag at all. In stories mode, finalize `{spec-folder}/RETROSPECTIVE.md` and stop there: no `sprint_status.py` call, no sprint-status file created, and no edits to `SPEC.md`, `stories.yaml`, or any story artifact.
Finalize the retrospective document and update sprint status. Read fully and follow `{{ rendered("references/retro-document.md") }}` for the document's sections, the exact `sprint_status.py update` invocation that marks the retro key `done`, appends the action items, and validates the write, and the terminal instruction that ends the run. Where the Phase 4 follow-through has evidence a *previous* epic's action item landed, offer `--set-action-status` and pass only the transitions the user confirms — the evidence justifies proposing a transition, and only the user's confirmation justifies writing it; a headless run records the transitions it would have proposed and does not pass the flag at all. In stories mode, finalize `{spec-folder}/RETROSPECTIVE.md` and stop there: no `sprint_status.py` call, no sprint-status file created, and no edits to `SPEC.md`, `stories.yaml`, or any story artifact.
@@ -15,7 +15,7 @@ The conversation context before this skill was triggered IS your starting point
Do the last few messages reveal what change the user wants reviewed? Look for spec paths, commit refs, branches, PRs, or descriptions of a change. Use the same routing as above.
3. **Sprint tracking**
Check for a sprint status file (`*sprint-status*`) in `{{.implementation_artifacts}}` or `{{.planning_artifacts}}`. If found, scan for stories with status `review`:
Check for a sprint status file (`*sprint-status*`) in `{{ config.implementation_artifacts }}` or `{{ config.planning_artifacts }}`. If found, scan for stories with status `review`:
- Exactly one → suggest it and confirm with the user.
- Multiple → present as numbered options.
- None → fall through.
@@ -38,7 +38,7 @@ Never ask extra questions beyond what the cascade prescribes. If a step above al
Once a change is identified from any source above, fill in the complementary artifact:
- If you have a spec, look for `baseline_commit` in its frontmatter to determine the diff baseline.
- If you have a commit or branch, check `{{.implementation_artifacts}}` for a spec whose `baseline_commit` is an ancestor of that commit/branch (i.e., the spec describes work done on top of that baseline).
- If you have a commit or branch, check `{{ config.implementation_artifacts }}` for a spec whose `baseline_commit` is an ancestor of that commit/branch (i.e., the spec describes work done on top of that baseline).
- If you found both a spec and a commit/branch, use both.
## DETERMINE WHAT YOU HAVE
@@ -96,8 +96,8 @@ Omit any metric you cannot compute rather than guessing.
## FALLBACK TRAIL GENERATION
If review mode is not `full-trail`, read fully and follow `[[bmad-snapshot:references/generate-trail.md]]` to build one from the diff. Then return here and continue to NEXT. If trail generation fails (e.g., git unavailable), the original review mode is preserved — step-02 handles this with its non-trail path.
If review mode is not `full-trail`, read fully and follow `{{ rendered("references/generate-trail.md") }}` to build one from the diff. Then return here and continue to NEXT. If trail generation fails (e.g., git unavailable), the original review mode is preserved — step-02 handles this with its non-trail path.
## NEXT
Read fully and follow `[[bmad-snapshot:step-02-walkthrough.md]]`
Read fully and follow `{{ rendered("step-02-walkthrough.md") }}`
@@ -80,10 +80,10 @@ When you're ready, say **next** and I'll surface the highest-risk spots.
If at any point the human signals they want to make a decision about this {change_type} (e.g., "let's ship it", "this needs a rethink", "I'm done reviewing", or anything suggesting they're ready to decide), confirm their intent:
- If they want to **approve and ship** → read fully and follow `[[bmad-snapshot:step-05-wrapup.md]]`
- If they want to **reject and rework** → read fully and follow `[[bmad-snapshot:step-05-wrapup.md]]`
- If they want to **approve and ship** → read fully and follow `{{ rendered("step-05-wrapup.md") }}`
- If they want to **reject and rework** → read fully and follow `{{ rendered("step-05-wrapup.md") }}`
- If you misread them → acknowledge and continue the current step.
## NEXT
Default: read fully and follow `[[bmad-snapshot:step-03-detail-pass.md]]`
Default: read fully and follow `{{ rendered("step-03-detail-pass.md") }}`
@@ -83,8 +83,8 @@ You've seen the design and the risk landscape. From here:
If at any point the human signals they want to make a decision about this {change_type} (e.g., "let's ship it", "this needs a rethink", "I'm done reviewing", or anything suggesting they're ready to decide), confirm their intent:
- If they want to **approve and ship** → read fully and follow `[[bmad-snapshot:step-05-wrapup.md]]`
- If they want to **reject and rework** → read fully and follow `[[bmad-snapshot:step-05-wrapup.md]]`
- If they want to **approve and ship** → read fully and follow `{{ rendered("step-05-wrapup.md") }}`
- If they want to **reject and rework** → read fully and follow `{{ rendered("step-05-wrapup.md") }}`
- If you misread them → acknowledge and continue the current step.
## TARGETED RE-REVIEW
@@ -103,4 +103,4 @@ The human can trigger multiple targeted re-reviews. Each time, present new findi
## NEXT
Read fully and follow `[[bmad-snapshot:step-04-testing.md]]`
Read fully and follow `{{ rendered("step-04-testing.md") }}`
+1 -1
View File
@@ -71,4 +71,4 @@ You've seen the change and how to verify it. When you're ready to make a call, j
## NEXT
When the human signals they're ready to make a decision about this {change_type}, read fully and follow `[[bmad-snapshot:step-05-wrapup.md]]`
When the human signals they're ready to make a decision about this {change_type}, read fully and follow `{{ rendered("step-05-wrapup.md") }}`
+1 -1
View File
@@ -25,4 +25,4 @@ HALT — do not proceed until the user makes their choice.
If anything appears below, follow it as the final terminal instruction before exiting; otherwise exit normally.
{workflow.on_complete}
{{ workflow.on_complete }}
+4 -4
View File
@@ -17,19 +17,19 @@
Execute each of these steps in order before proceeding (`_None._` means skip):
{workflow.activation_steps_prepend}
{{ workflow.activation_steps_prepend }}
### Step 2: Load Persistent Facts
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):
{workflow.persistent_facts}
{{ workflow.persistent_facts }}
### Step 3: Execute Append Steps
Execute each of these steps in order (`_None._` means skip):
{workflow.activation_steps_append}
{{ workflow.activation_steps_append }}
Activation is complete after all activation steps have run.
@@ -44,4 +44,4 @@ Follow the step files in order. Read one step fully, execute it, then load the n
## FIRST STEP
Read fully and follow: `[[bmad-snapshot:step-01-orientation.md]]` to begin.
Read fully and follow: `{{ rendered("step-01-orientation.md") }}` to begin.
+244 -151
View File
@@ -1,6 +1,7 @@
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# dependencies = ["jinja2>=3.1"]
# ///
"""Render a skill's Markdown sources into an immutable project snapshot."""
@@ -19,6 +20,8 @@ from datetime import date, time
from pathlib import Path
from typing import Any
import jinja2
# Installed scripts are consumer files, not a location for interpreter caches.
sys.dont_write_bytecode = True
@@ -40,13 +43,7 @@ class _ArgumentParser(argparse.ArgumentParser):
raise RenderError(message)
_CONFIG_TOKEN = re.compile(r"\{\{config\.([A-Za-z0-9_.-]+)\}\}")
_SHORT_CONFIG_TOKEN = re.compile(r"\{\{\.([A-Za-z0-9_]+)\}\}")
_CUSTOM_TOKEN = re.compile(r"\{workflow\.([A-Za-z0-9_.-]+)\}")
_SNAPSHOT_TOKEN = re.compile(r"\[\[bmad-snapshot:([A-Za-z0-9_./-]+\.md)\]\]")
_PARAMETER = r"[A-Za-z0-9_-]+(?:\.[A-Za-z0-9_-]+)*"
_CONDITION = re.compile(rf"\[\[bmad-if:({_PARAMETER})\s*(==|!=)\s*(.+)\]\]")
_DIRECTIVE = re.compile(r"\[\[bmad-(?:if|else|endif)\b")
def _hash_bytes(content: bytes) -> str:
@@ -113,61 +110,6 @@ def _leaf_paths(table: dict[str, Any], prefix: str = "") -> set[str]:
return leaves
def _filter_conditions(
sources: dict[str, str], customization: dict[str, Any], defaults: dict[str, Any] | None
) -> tuple[dict[str, str], dict[str, Any]]:
filtered: dict[str, str] = {}
inputs: dict[str, Any] = {}
for name, content in sources.items():
output: list[str] = []
# Each frame keeps the enclosing state, comparison, else marker, and line.
stack: list[tuple[bool, bool, bool, int]] = []
active = True
had_condition = False
for line_number, line in enumerate(content.splitlines(keepends=True), 1):
directive = line.strip()
location = f"{name}:{line_number}"
match = _CONDITION.fullmatch(directive)
if match:
had_condition = True
path, operator, literal = match.groups()
try:
default = _lookup(defaults or {}, path, "customization default")
if isinstance(default, (dict, list)):
raise RenderError(f"condition parameter `{path}` must be a scalar")
expected = _toml_literal(literal, path)
value = _lookup(customization, path, "customization value")
except RenderError as error:
raise RenderError(f"{location}: {error}") from error
inputs[f"customization.{path}"] = value
matches = value == expected if operator == "==" else value != expected
stack.append((active, matches, False, line_number))
active = active and matches
elif directive == "[[bmad-else]]":
if not stack or stack[-1][2]:
raise RenderError(f"{location}: unexpected or duplicate bmad-else")
parent, matches, _, opening = stack[-1]
stack[-1] = (parent, matches, True, opening)
active = parent and not matches
elif directive == "[[bmad-endif]]":
if not stack:
raise RenderError(f"{location}: unexpected bmad-endif")
active = stack.pop()[0]
elif _DIRECTIVE.search(line):
raise RenderError(f"{location}: invalid conditional directive; use a standalone directive line")
elif active:
output.append(line)
if stack:
raise RenderError(f"{name}:{stack[-1][3]}: unclosed bmad-if")
text = "".join(output)
if had_condition and not text.strip():
if name == "workflow.md":
raise RenderError(f"{name}: conditional rendering excluded the entry")
else:
filtered[name] = text
return filtered, inputs
def _lookup(data: dict[str, Any], dotted_path: str, label: str) -> Any:
current: Any = data
for part in dotted_path.split("."):
@@ -296,95 +238,232 @@ def _format_review_layers(layers: list[dict[str, str]]) -> str:
return "\n\n".join(sections)
def _resolve_customization_value(value: Any, default: Any, label: str) -> tuple[Any, str]:
def _resolve_customization_value(value: Any, default: Any, label: str) -> Any:
"""Validate an effective customization leaf against the shape of its shipped default."""
if isinstance(default, str):
allow_empty = not default.strip() or label == "customization.workflow.open_spec"
resolved = _require_string(value, label, allow_empty=allow_empty)
return resolved, resolved
return _require_string(value, label, allow_empty=allow_empty)
if isinstance(default, list):
if default and all(isinstance(item, dict) for item in default):
resolved = _require_review_layers(value, label)
return resolved, _format_review_layers(resolved)
resolved = _require_string_list(value, label)
return resolved, _format_markdown_list(resolved)
return _require_review_layers(value, label)
return _require_string_list(value, label)
if isinstance(default, (bool, int, float, date, time)):
if type(value) is not type(default):
raise RenderError(f"{label} must be {type(default).__name__}, got {type(value).__name__}")
return value
raise RenderError(f"{label} has unsupported default type {type(default).__name__}")
def _resolve_replacements(
sources: dict[str, str],
central: dict[str, Any],
customization: dict[str, Any],
defaults: dict[str, Any] | None,
project_root: Path,
) -> tuple[dict[str, str], dict[str, Any]]:
replacements: dict[str, str] = {}
input_values: dict[str, Any] = {}
for content in sources.values():
for match in _SHORT_CONFIG_TOKEN.finditer(content):
token, key = match.group(0), match.group(1)
path, resolved = _resolve_short_config(central, key, project_root)
source = f"config.{path}"
replacements[token] = resolved
input_values[source] = resolved
for match in _CONFIG_TOKEN.finditer(content):
token, path = match.group(0), match.group(1)
source = f"config.{path}"
resolved = _resolve_config_value(_lookup(central, path, "config value"), source, project_root)
replacements[token] = resolved
input_values[source] = resolved
for match in _CUSTOM_TOKEN.finditer(content):
if defaults is None:
raise RenderError("customization tokens require customize.toml")
token, relative_path = match.group(0), match.group(1)
path = f"workflow.{relative_path}"
source = f"customization.{path}"
resolved, rendered = _resolve_customization_value(
_lookup(customization, path, "customization value"),
_lookup(defaults, path, "customization default"),
source,
class _Text(str):
"""A customization string. Looping over one is a template mistake, not a walk over its characters."""
def __new__(cls, value: str, label: str) -> _Text:
text = super().__new__(cls, value)
text.label = label
return text
def __iter__(self):
raise RenderError(f"`{self.label}` is a string, not a list")
class _MarkdownList(list):
"""A string-list customization; inserted directly it renders as the Markdown list it always did."""
def __str__(self) -> str:
return _format_markdown_list(list(self))
class _LayerList(list):
"""A review-layer customization; inserted directly it renders as lens sections."""
def __str__(self) -> str:
return _format_review_layers(list(self))
def _bind_customization(value: Any, label: str, destination: Path) -> Any:
"""Bind `{skill-root}` in customization prose to the generation and wrap lists for insertion."""
root = str(destination)
if isinstance(value, str):
return _Text(value.replace("{skill-root}", root), label)
if isinstance(value, list):
if value and all(isinstance(item, dict) for item in value):
return _LayerList(
[{key: text.replace("{skill-root}", root) for key, text in layer.items()} for layer in value]
)
replacements[token] = rendered
input_values[source] = resolved
return replacements, input_values
return _MarkdownList([item.replace("{skill-root}", root) for item in value])
return value
def _render_sources(
sources: dict[str, str], replacements: dict[str, str], destination: Path, skill_dir: Path
) -> dict[str, str]:
"""Resolve only tokens authored in installed sources in one opaque pass."""
# Workflow customization may reference installed skill files; bind those
# references to the immutable generation before inserting the prose.
replacements = {
token: value.replace("{skill-root}", str(destination)) if token.startswith("{workflow.") else value
for token, value in replacements.items()
}
class _Table:
"""A dotted namespace over a TOML table. Names never hit Python attributes, so `workflow.items` is a lookup."""
def __init__(self, path: str) -> None:
self._path = path
def _child(self, name: str) -> str:
return f"{self._path}.{name}"
def _resolve(self, name: str) -> Any:
raise NotImplementedError
def __getattr__(self, name: str) -> Any:
if name.startswith("_"):
raise AttributeError(name)
return self._resolve(name)
def __getitem__(self, name: Any) -> Any:
if not isinstance(name, str):
raise RenderError(f"`{self._path}` is indexed by name, not {name!r}")
return self._resolve(name)
def __str__(self) -> str:
raise RenderError(f"`{self._path}` is a table, not a value")
class _ConfigTable(_Table):
"""`config.key` is the short lookup of one scalar anywhere in the central config; `config.a.b.c` is a path."""
def __init__(self, central: dict[str, Any], table: dict[str, Any], path: str, ctx: _RenderContext) -> None:
super().__init__(path)
self._central = central
self._table = table
self._ctx = ctx
def _resolve(self, name: str) -> Any:
if self._path == "config" and name not in self._table:
path, resolved = _resolve_short_config(self._central, name, self._ctx.project_root)
self._ctx.inputs[f"config.{path}"] = resolved
return _Text(resolved, f"config.{path}")
label = self._child(name)
if name not in self._table:
raise RenderError(f"missing config value `{label.removeprefix('config.')}`")
value = self._table[name]
if isinstance(value, dict):
return _ConfigTable(self._central, value, label, self._ctx)
resolved = _resolve_config_value(value, label, self._ctx.project_root)
self._ctx.inputs[label] = resolved
return _Text(resolved, label)
class _CustomizationTable(_Table):
"""The effective customization, each leaf validated against its `customize.toml` default."""
def __init__(self, defaults: dict[str, Any] | None, values: dict[str, Any], path: str, ctx: _RenderContext) -> None:
super().__init__(path)
self._defaults = defaults
self._values = values
self._ctx = ctx
def _resolve(self, name: str) -> Any:
path = self._child(name)
if self._defaults is None:
raise RenderError(f"`{path}` requires customize.toml")
if name not in self._defaults:
raise RenderError(f"missing customization parameter `{path}`")
if name not in self._values:
raise RenderError(f"missing customization value `{path}`")
default, value = self._defaults[name], self._values[name]
label = f"customization.{path}"
if isinstance(default, dict):
if not isinstance(value, dict):
raise RenderError(f"{label} must be a table, got {type(value).__name__}")
return _CustomizationTable(default, value, path, self._ctx)
resolved = _resolve_customization_value(value, default, label)
self._ctx.inputs[label] = resolved
return _bind_customization(resolved, label, self._ctx.destination)
class _RenderContext:
"""One rendering pass: the values it serves and the rendered() links each source makes."""
def __init__(
self,
*,
central: dict[str, Any],
defaults: dict[str, Any] | None,
customization: dict[str, Any],
source_names: set[str],
project_root: Path,
destination: Path,
) -> None:
self.project_root = project_root
self.destination = destination
self.inputs: dict[str, Any] = {}
self.links: dict[str, set[str]] = {}
self._source_names = source_names
self.variables = {
"config": _ConfigTable(central, central, "config", self),
"workflow": _CustomizationTable(
None if defaults is None else defaults.get("workflow", {}),
customization.get("workflow", {}),
"workflow",
self,
),
"rendered": self._rendered,
}
@jinja2.pass_context
def _rendered(self, context: jinja2.runtime.Context, target: Any) -> str:
if not isinstance(target, str) or target not in self._source_names:
raise RenderError(f"rendered() targets undeclared source: {target}")
self.links.setdefault(context.name or "", set()).add(target)
return str(self.destination / target)
class _SourceLoader(jinja2.BaseLoader):
"""Serve sources by name, and name them so template frames carry `source:line`."""
def __init__(self, sources: dict[str, str]) -> None:
self._sources = sources
def get_source(self, environment: jinja2.Environment, template: str) -> tuple[str, str, Any]:
if template not in self._sources:
raise jinja2.TemplateNotFound(template)
return self._sources[template], template, lambda: True
def _template_location(error: BaseException, source_names: set[str]) -> str | None:
if isinstance(error, jinja2.TemplateSyntaxError):
return f"{error.name}:{error.lineno}" if error.name else None
location = None
traceback = error.__traceback__
while traceback is not None:
filename = traceback.tb_frame.f_code.co_filename
if filename in source_names:
location = f"{filename}:{traceback.tb_lineno}"
traceback = traceback.tb_next
return location
def _render_sources(sources: dict[str, str], skill_dir: Path, context: _RenderContext) -> dict[str, str]:
"""Render every source as a Jinja2 template against the context; return the non-empty outputs."""
# Skill sources name their bundled non-Markdown files (scripts, assets)
# through {skill-root}; those stay in the installed skill directory.
replacements["{skill-root}"] = str(skill_dir)
source_names = set(sources)
patterns = [
*(re.escape(token) for token in sorted(replacements, key=len, reverse=True)),
_SNAPSHOT_TOKEN.pattern,
]
token_pattern = re.compile("|".join(patterns))
def replace(match: re.Match[str]) -> str:
token = match.group(0)
if token in replacements:
return replacements[token]
snapshot = _SNAPSHOT_TOKEN.fullmatch(token)
if snapshot is None:
raise RenderError(f"unsupported render token: {token}")
target = snapshot.group(1)
if target not in source_names:
raise RenderError(f"snapshot reference targets undeclared source: {target}")
return str(destination / target)
bound = {name: content.replace("{skill-root}", str(skill_dir)) for name, content in sources.items()}
environment = jinja2.Environment(
loader=_SourceLoader(bound),
undefined=jinja2.StrictUndefined,
autoescape=False,
keep_trailing_newline=True,
trim_blocks=True,
lstrip_blocks=True,
)
rendered: dict[str, str] = {}
for name, content in sources.items():
# Inserted paths and customization prose are never scanned as source tokens.
rendered[name] = token_pattern.sub(replace, content)
return rendered
for name in sources:
try:
rendered[name] = environment.get_template(name).render(context.variables)
except Exception as error:
location = _template_location(error, set(sources))
message = str(error) if isinstance(error, (RenderError, ConfigError, jinja2.TemplateError)) else repr(error)
raise RenderError(f"{location or name}: {message}") from error
# A source whose body renders to nothing is left out; links into it from survivors are broken.
omitted = {name for name, text in rendered.items() if not text.strip()}
if "workflow.md" in omitted:
raise RenderError("workflow.md: rendered empty")
for name in sorted(set(rendered) - omitted):
for target in sorted(context.links.get(name, set()) & omitted):
raise RenderError(f"{name}: rendered() targets omitted source: {target}")
return {name: text for name, text in rendered.items() if name not in omitted}
def _verify_existing(destination: Path, manifest: dict[str, Any]) -> None:
@@ -445,9 +524,7 @@ def render(
sources = _load_sources(skill_dir)
central = load_central_config(project_root)
has_customization = bool(overrides is not None or assignments) or any(
_CUSTOM_TOKEN.search(content) or _DIRECTIVE.search(content) for content in sources.values()
)
has_customization = bool(overrides is not None or assignments) or (skill_dir / "customize.toml").is_file()
defaults = load_toml(skill_dir / "customize.toml", required=True) if has_customization else None
customization = load_customization(project_root, skill_dir) if has_customization else {}
supplied: set[str] = set()
@@ -455,30 +532,46 @@ def render(
file_layer, command_layer = _invocation_customization(defaults, overrides, assignments or [])
customization = structural_merge(structural_merge(customization, file_layer), command_layer)
supplied = _leaf_paths(file_layer) | _leaf_paths(command_layer)
selected_sources, condition_inputs = _filter_conditions(sources, customization, defaults)
replacements, input_values = _resolve_replacements(selected_sources, central, customization, defaults, project_root)
input_values.update(condition_inputs)
# Every invocation override must reach a token or condition; values are validated where consumed.
unused = sorted(path for path in supplied if f"customization.{path}" not in input_values)
if unused:
raise RenderError(f"invocation override not used by this render: {', '.join(unused)}")
# Store TOML date/time inputs in the same JSON representation used on disk.
input_values = json.loads(_canonical_json(input_values))
source_hashes = {name: _hash_bytes(content.encode("utf-8")) for name, content in sources.items()}
root_hash = _hash_bytes(str(project_root).encode("utf-8"))[:12]
slug = re.sub(r"[^a-z0-9]+", "-", project_root.name.lower()).strip("-") or "project"
slug = slug[:80].rstrip("-") or "project"
namespace = project_root / "_bmad" / "render" / skill_dir.name / f"{slug}-{root_hash}"
def render_pass(destination: Path) -> tuple[_RenderContext, dict[str, str]]:
context = _RenderContext(
central=central,
defaults=defaults,
customization=customization,
source_names=set(sources),
project_root=project_root,
destination=destination,
)
return context, _render_sources(sources, skill_dir, context)
# The generation path is keyed by the values the templates reach, and the
# templates insert that path, so a first pass against a placeholder
# destination collects the inputs and the real pass renders the output.
probe, _ = render_pass(namespace / "pending")
# An override may only name a key some template actually read; values are validated where consumed.
unused = sorted(path for path in supplied if f"customization.{path}" not in probe.inputs)
if unused:
raise RenderError(f"invocation override not used by this render: {', '.join(unused)}")
# Store TOML date/time inputs in the same JSON representation used on disk.
input_values = json.loads(_canonical_json(probe.inputs))
renderer_hash = _hash_bytes(Path(__file__).read_bytes())
identity = {
"project_root": str(project_root),
"skill_root": str(skill_dir),
"renderer_sha256": renderer_hash,
"jinja2_version": jinja2.__version__,
"resolved_values": input_values,
"source_sha256": source_hashes,
}
generation_hash = _hash_bytes(_canonical_json(identity))[:20]
destination = project_root / "_bmad" / "render" / skill_dir.name / f"{slug}-{root_hash}" / generation_hash
rendered = _render_sources(selected_sources, replacements, destination, skill_dir)
destination = namespace / generation_hash
_, rendered = render_pass(destination)
outputs = {name: content.encode("utf-8") for name, content in rendered.items()}
output_hashes = {name: _hash_bytes(content) for name, content in outputs.items()}
manifest = {
+126 -63
View File
@@ -22,6 +22,7 @@ import unittest
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from types import SimpleNamespace
from unittest import mock
SCRIPTS_SRC = Path(__file__).resolve().parents[1]
REPO = SCRIPTS_SRC.parents[2]
@@ -36,7 +37,7 @@ SHARED_SCRIPTS = (
)
SHIPPED_SKILLS = ("bmad-build-auto", "bmad-build", "bmad-code-review")
RENDERED_SKILLS = (*SHIPPED_SKILLS, "bmad-walkthrough", "bmad-retrospective")
COMPILE_TOKEN = re.compile(r"\{\{(?:\.|config\.)|\{workflow\.|\[\[bmad-snapshot:")
COMPILE_TOKEN = re.compile(r"\{\{\s*(?:config|workflow)\.|\{\{\s*rendered\(|\{%")
DISPATCH_PREFIX = "read and follow "
sys.path.insert(0, str(SCRIPTS_SRC))
@@ -168,7 +169,7 @@ class RenderSkillTests(unittest.TestCase):
self.assertTrue(output.is_absolute())
return output
def _assert_snapshot(self, workflow: Path, project: Path, skill_name: str) -> Path:
def _assert_rendered(self, workflow: Path, project: Path, skill_name: str) -> Path:
snap = workflow.parent
self.assertEqual(workflow.name, "workflow.md")
self.assertIn(f"{os.sep}render{os.sep}{skill_name}{os.sep}", str(workflow))
@@ -205,7 +206,7 @@ class RenderSkillTests(unittest.TestCase):
def test_invocation_precedence_flag_order_and_persistent_isolation(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{workflow.message}\n")
skill = self._fixture_skill(ws, '[workflow]\nmessage = "shipped"\n', "{{ workflow.message }}\n")
project_file = ws.bmad / "custom" / "fixture.toml"
user_file = ws.bmad / "custom" / "fixture.user.toml"
override_file = ws.project / "nested" / "cwd" / "invocation.toml"
@@ -247,13 +248,13 @@ class RenderSkillTests(unittest.TestCase):
project_file.unlink()
self.assertEqual(rs.render(ws.project, skill).read_text(), "shipped\n")
def test_equivalent_invocation_forms_merge_structures_and_reuse_snapshot(self):
def test_equivalent_invocation_forms_merge_structures_and_reuse_rendered(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nfacts = ["base"]\n[workflow.details]\nlabel = "base"\nkept = "kept"\n'
'[[workflow.layers]]\nid = "a"\nname = "A"\ninstruction = "original"\n',
"{workflow.facts}\n{workflow.details.label} {workflow.details.kept}\n{workflow.layers}\n",
"{{ workflow.facts }}\n{{ workflow.details.label }} {{ workflow.details.kept }}\n{{ workflow.layers }}\n",
)
override = ws.project / "overrides.toml"
override.write_text(
@@ -286,7 +287,7 @@ class RenderSkillTests(unittest.TestCase):
def test_string_assignment_syntax_and_conflicting_paths_halt(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\n', "{workflow.message}")
skill = self._fixture_skill(ws, '[workflow]\nmessage = "base"\n', "{{ workflow.message }}")
for assignment, expected in (
("workflow.message=words = more words", "words = more words"),
('workflow.message="line\\nnext"', "line\nnext"),
@@ -332,39 +333,40 @@ class RenderSkillTests(unittest.TestCase):
skill = self._fixture_skill(
ws,
'[workflow]\nmessage = "base"\ncount = 1\nitems = ["base"]\n',
"{workflow.message} {workflow.items}\n",
"{{ workflow.message }} {{ workflow.items }}\n",
)
self._assert_halt(self._cli(ws.project, skill, args=args), ws)
for content in ("[workflow", '[workflow]\nunknown="x"', "[workflow]\ncount=7", "[workflow]\nitems=7"):
with self.subTest(content=content):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\ncount = 1\nitems = ["base"]\n', "{workflow.items}\n")
skill = self._fixture_skill(ws, '[workflow]\ncount = 1\nitems = ["base"]\n', "{{ workflow.items }}\n")
override = ws.project / "bad.toml"
override.write_text(content, encoding="utf-8")
self._assert_halt(self._cli(ws.project, skill, args=("--overrides", str(override))), ws)
def test_nested_conditions_omit_files_and_do_not_resolve_excluded_tokens(self):
def test_nested_conditions_omit_files_and_do_not_resolve_excluded_expressions(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nchoice = "left"\nenabled = true\n',
'[[bmad-if:workflow.choice == "left"]]\nLeft [[bmad-snapshot:left.md]]\n'
"[[bmad-if:workflow.enabled != false]]\nEnabled\n[[bmad-else]]\n"
"{{config.missing}} {workflow.missing} [[bmad-snapshot:missing.md]]\n[[bmad-endif]]\n"
"[[bmad-else]]\nRight [[bmad-snapshot:right.md]]\n[[bmad-endif]]\n",
'{% if workflow.choice == "left" %}\nLeft {{ rendered("left.md") }}\n'
"{% if workflow.enabled %}\nEnabled\n{% else %}\n"
'{{ config.missing }} {{ workflow.missing }} {{ rendered("missing.md") }}\n{% endif %}\n'
'{% else %}\nRight {{ rendered("right.md") }}\n{% endif %}\n',
**{
"left.md": '[[bmad-if:workflow.choice == "left"]]\nLeft detail\n[[bmad-endif]]\n',
"right.md": '\n[[bmad-if:workflow.choice != "left"]]\nRight detail\n[[bmad-endif]]\n',
"left.md": '{% if workflow.choice == "left" %}\nLeft detail\n{% endif %}\n',
"right.md": '\n{% if workflow.choice != "left" %}\nRight detail\n{% endif %}\n',
},
)
left = rs.render(ws.project, skill)
before = _files(left.parent)
self.assertIn("Enabled", left.read_text())
self.assertTrue((left.parent / "left.md").exists())
# Standalone tag lines leave no blank lines behind.
self.assertEqual(left.read_text(), f"Left {left.parent / 'left.md'}\nEnabled\n")
self.assertEqual((left.parent / "left.md").read_text(), "Left detail\n")
self.assertFalse((left.parent / "right.md").exists())
right = rs.render(ws.project, skill, assignments=["workflow.choice=right"])
self.assertNotEqual(left, right)
self.assertIn("Right", right.read_text())
self.assertEqual(right.read_text(), f"Right {right.parent / 'right.md'}\n")
self.assertFalse((right.parent / "left.md").exists())
self.assertTrue((right.parent / "right.md").exists())
self.assertEqual(before, _files(left.parent))
@@ -383,19 +385,19 @@ class RenderSkillTests(unittest.TestCase):
self.assertNotEqual(left, rs.render(ws.project, skill))
def test_typed_scalar_condition_inputs_identify_generations_even_for_identical_output(self):
for default, literal, override in (
("true", "true", "false"),
("1", "1", "2"),
("1.5", "1.5", "2.5"),
('"one"', '"one"', '"two"'),
("2026-09-07", "2026-09-07", "2026-09-08"),
for default, condition, override in (
("true", "workflow.value == true", "false"),
("1", "workflow.value == 1", "2"),
("1.5", "workflow.value == 1.5", "2.5"),
('"one"', 'workflow.value == "one"', '"two"'),
("2026-09-07", 'workflow.value|string == "2026-09-07"', "2026-09-08"),
):
with self.subTest(default=default):
ws = self._workspace()
skill = self._fixture_skill(
ws,
f"[workflow]\nvalue = {default}\n",
f"[[bmad-if:workflow.value == {literal}]]\nSame\n[[bmad-else]]\nSame\n[[bmad-endif]]\n",
f"{{% if {condition} %}}\nSame\n{{% else %}}\nSame\n{{% endif %}}\n",
)
before = rs.render(ws.project, skill)
after = rs.render(ws.project, skill, assignments=[f"workflow.value={override}"])
@@ -403,32 +405,76 @@ class RenderSkillTests(unittest.TestCase):
self.assertNotEqual(before, after)
self.assertEqual(after, rs.render(ws.project, skill, assignments=[f"workflow.value={override}"]))
def test_invalid_conditions_report_source_location_and_halt(self):
directives = (
'[[bmad-if:workflow.value = "one"]]\n',
'prefix [[bmad-if:workflow.value == "one"]]\n',
'[[bmad-if:workflow.value == "one"]]\ntext\n',
"[[bmad-else]]\n",
"[[bmad-endif]]\n",
'[[bmad-if:workflow.value == "one"]]\n[[bmad-else]]\n[[bmad-else]]\n[[bmad-endif]]\n',
'[[bmad-if:workflow.unknown == "one"]]\n[[bmad-endif]]\n',
"[[bmad-if:workflow.value == one]]\n[[bmad-endif]]\n",
"[[bmad-if:workflow.items == []]]\n[[bmad-endif]]\n",
'[[bmad-if:workflow.value == "one" or true]]\n[[bmad-endif]]\n',
'[[bmad-if:workflow.value != "one"]]\n[[bmad-if:broken]]\n[[bmad-endif]]\n',
def test_loops_iterate_list_values_and_direct_insertion_keeps_markdown_forms(self):
ws = self._workspace()
skill = self._fixture_skill(
ws,
'[workflow]\nfacts = ["one", "two"]\n[[workflow.layers]]\nid = "a"\nname = "A"\n'
'instruction = "Read {skill-root}/a.md"\n[[workflow.layers]]\nid = "b"\ninstruction = ""\n',
"{% for fact in workflow.facts %}\n* {{ fact }}\n{% endfor %}\n{{ workflow.facts }}\n"
"{% for layer in workflow.layers if layer.instruction %}\n{{ layer.id }}: {{ layer.instruction }}\n"
"{% endfor %}\n{{ workflow.layers }}\n",
)
for directive in directives:
with self.subTest(directive=directive):
entry = rs.render(ws.project, skill)
snap = entry.parent
self.assertEqual(
entry.read_text(),
f"* one\n* two\n- one\n- two\na: Read {snap}/a.md\n#### A (`a`)\n\nRead {snap}/a.md\n",
)
manifest = json.loads((snap / "manifest.json").read_text())
self.assertEqual(
manifest["inputs"]["resolved_values"],
{
"customization.workflow.facts": ["one", "two"],
"customization.workflow.layers": [
{"id": "a", "name": "A", "instruction": "Read {skill-root}/a.md"},
{"id": "b", "name": "b", "instruction": ""},
],
},
)
def test_template_errors_report_source_location_and_halt(self):
templates = (
'{% if workflow.value == "one" %}\ntext\n',
"{% else %}\n",
"{% endif %}\n",
"{% if %}\n{% endif %}\n",
"{{ workflow.value\n",
"{{ workflow.unknown }}\n",
"{{ nothing }}\n",
"{{ workflow }}\n",
"{{ config }}\n",
"{{ config.nothing }}\n",
"{{ config.core.nothing }}\n",
"{{ workflow.items.missing }}\n",
"{% for item in workflow.value %}{{ item }}{% endfor %}\n",
"{% for item in workflow.count %}{{ item }}{% endfor %}\n",
'{{ rendered("missing.md") }}\n',
"{{ rendered(workflow.items) }}\n",
)
for template in templates:
with self.subTest(template=template):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nvalue = "one"\nitems = []\n', directive)
skill = self._fixture_skill(ws, '[workflow]\nvalue = "one"\ncount = 1\nitems = []\n', f"ok\n{template}")
result = self._cli(ws.project, skill)
self._assert_halt(result, ws)
self.assertRegex(result.stdout, r"workflow\.md:\d+:")
self.assertRegex(result.stdout, r"^HALT: workflow\.md:\d+: ")
ws = self._workspace()
skill = self._fixture_skill(
ws, '[workflow]\nvalue = "one"\n', "entry\n", **{"detail.md": "\n\n{{ nothing }}\n"}
)
with self.assertRaisesRegex(rs.RenderError, r"^detail\.md:3: 'nothing' is undefined$"):
rs.render(ws.project, skill)
(skill / "detail.md").write_text("{{ workflow.missing }}\n", encoding="utf-8")
with self.assertRaisesRegex(
rs.RenderError, r"^detail\.md:1: missing customization parameter `workflow\.missing`$"
):
rs.render(ws.project, skill)
def test_excluded_entry_and_surviving_reference_to_excluded_file_halt(self):
for workflow in (
"[[bmad-if:workflow.enabled == false]]\nExcluded\n[[bmad-endif]]\n",
"Read [[bmad-snapshot:detail.md]]\n",
"{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n",
'Read {{ rendered("detail.md") }}\n',
):
with self.subTest(workflow=workflow):
ws = self._workspace()
@@ -436,23 +482,38 @@ class RenderSkillTests(unittest.TestCase):
ws,
"[workflow]\nenabled = true\n",
workflow,
**{"detail.md": "[[bmad-if:workflow.enabled == false]]\nExcluded\n[[bmad-endif]]\n"},
**{"detail.md": "{% if workflow.enabled == false %}\nExcluded\n{% endif %}\n"},
)
self._assert_halt(self._cli(ws.project, skill), ws)
def test_invocation_prose_keeps_conditional_and_compile_tokens_opaque(self):
def test_raw_blocks_keep_agent_placeholders_verbatim(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = ""\n', "{workflow.message}\n")
literal = "[[bmad-if:workflow.missing == true]]\n{workflow.missing} {{config.missing}}\n[[bmad-endif]]"
literal += "\n[[bmad-snapshot:missing.md]] {skill-root}/detail.md"
skill = self._fixture_skill(
ws,
"[workflow]\n",
"epic: {% raw %}{{epic_number}}{% endraw +%}\nnext {% raw %}{{prev}}{% endraw %} {{ '{%' }} done\n",
)
self.assertEqual(rs.render(ws.project, skill).read_text(), "epic: {{epic_number}}\nnext {{prev}} {% done\n")
def test_invocation_prose_keeps_template_syntax_opaque(self):
ws = self._workspace()
skill = self._fixture_skill(ws, '[workflow]\nmessage = ""\n', "{{ workflow.message }}\n")
literal = "{% if workflow.missing %}\n{{ workflow.missing }} {{ config.missing }} {# note #}\n{% endif %}"
literal += '\n{{ rendered("missing.md") }} {skill-root}/detail.md'
entry = rs.render(ws.project, skill, assignments=[f"workflow.message={literal}"])
self.assertEqual(entry.read_text(), literal.replace("{skill-root}", str(entry.parent)) + "\n")
def test_unsupported_customization_default_type_is_rejected(self):
# No shipped skill uses a boolean customization default; arranging one
# through customize.toml would only exist to reach this branch.
with self.assertRaisesRegex(rs.RenderError, "unsupported default type"):
rs._resolve_customization_value(True, True, "customization.workflow.flag")
def test_jinja2_version_is_part_of_the_generation_identity(self):
ws = self._workspace()
skill = self._fixture_skill(ws, "[workflow]\n", "stable\n")
original = rs.render(ws.project, skill)
manifest = json.loads((original.parent / "manifest.json").read_text())
self.assertEqual(manifest["inputs"]["jinja2_version"], rs.jinja2.__version__)
with mock.patch.object(rs.jinja2, "__version__", "0.0.0"):
changed = rs.render(ws.project, skill)
self.assertNotEqual(changed, original)
self.assertEqual(changed.read_bytes(), original.read_bytes())
self.assertTrue(original.exists())
def test_shipped_skills_publish_root_bound_snapshots(self):
for name in SHIPPED_SKILLS:
@@ -460,7 +521,7 @@ class RenderSkillTests(unittest.TestCase):
ws = self._workspace()
skill = self._skill(ws, name)
workflow = rs.render(ws.project, skill)
snap = self._assert_snapshot(workflow, ws.project, name)
snap = self._assert_rendered(workflow, ws.project, name)
self.assertIn("{spec_file}", _markdown(snap))
hunter = snap / "review-prompts" / "edge-case-hunter.md"
self.assertTrue(hunter.is_file())
@@ -472,14 +533,16 @@ class RenderSkillTests(unittest.TestCase):
ws = self._workspace()
skill = self._skill(ws, name)
workflow = rs.render(ws.project, skill)
self._assert_snapshot(workflow, ws.project, name)
self._assert_rendered(workflow, ws.project, name)
def test_skill_root_binds_bundled_scripts_to_the_installed_skill(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-retrospective")
snap = self._assert_snapshot(rs.render(ws.project, skill), ws.project, "bmad-retrospective")
snap = self._assert_rendered(rs.render(ws.project, skill), ws.project, "bmad-retrospective")
markdown = _markdown(snap)
self.assertIn(str(skill / "scripts" / "sprint_status.py"), markdown)
self.assertIn("epic: {{epic_number}}\n", markdown)
self.assertIn("epic-{{prev}}-retro-*.md", markdown)
self.assertIn(str(skill / "scripts" / "git_evidence.py"), markdown)
manifest = json.loads((snap / "manifest.json").read_text(encoding="utf-8"))
self.assertEqual(manifest["inputs"]["skill_root"], str(skill.resolve()))
@@ -492,7 +555,7 @@ class RenderSkillTests(unittest.TestCase):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
workflow = self._entry(self._cli(ws.project, skill, cwd=ws.project / "nested" / "cwd"))
self._assert_snapshot(workflow, ws.project, "bmad-build")
self._assert_rendered(workflow, ws.project, "bmad-build")
self.assertFalse((ws.bmad / "scripts" / "__pycache__").exists())
self.assertFalse((skill / "__pycache__").exists())
@@ -600,10 +663,10 @@ class RenderSkillTests(unittest.TestCase):
def test_customization_prose_is_not_rescanned_as_source_tokens(self):
ws = self._workspace()
skill = self._skill(ws, "bmad-build")
literal = "[[bmad-snapshot:step-04-review.md]]"
compile_literal = "{workflow.implementation_handoff}"
literal = '{{ rendered("step-04-review.md") }}'
compile_literal = "{{ workflow.implementation_handoff }}"
(ws.bmad / "custom" / f"{skill.name}.user.toml").write_text(
f'[workflow]\non_complete = "Preserve {literal} and {compile_literal} as prose"\n',
f"[workflow]\non_complete = 'Preserve {literal} and {compile_literal} as prose'\n",
encoding="utf-8",
)
markdown = _markdown(rs.render(ws.project, skill).parent)
@@ -666,7 +729,7 @@ class RenderSkillTests(unittest.TestCase):
ws = self._workspace()
skill = ws.outer / "skills" / "plain-workflow"
skill.mkdir(parents=True)
(skill / "workflow.md").write_text("Read `[[bmad-snapshot:step.md]]`.\n", encoding="utf-8")
(skill / "workflow.md").write_text('Read `{{ rendered("step.md") }}`.\n', encoding="utf-8")
(skill / "step.md").write_text("No rendered values required.\n", encoding="utf-8")
workflow = rs.render(ws.project, skill)
self.assertIn(f"{os.sep}render{os.sep}plain-workflow{os.sep}", str(workflow))
@@ -700,7 +763,7 @@ class RenderSkillTests(unittest.TestCase):
self.assertLessEqual(len(workflow.parent.parent.name), 93)
def test_snapshot_paths_stay_opaque_when_the_project_name_looks_like_tokens(self):
ws = self._workspace(name="{workflow.on_complete}-{{.planning_artifacts}}")
ws = self._workspace(name="{{ workflow.on_complete }}-{{ config.planning_artifacts }}")
skill = self._skill(ws, "bmad-build")
workflow = rs.render(ws.project, skill)
text = workflow.read_text(encoding="utf-8")
+36 -28
View File
@@ -36,7 +36,7 @@ If no findings are generated (from either pass), the skill passes validation.
- **Customization value**: a key from the skill's own `customize.toml`, in its `[workflow]` table (most skills) or `[agent]` table (agent skills), layered with `_bmad/custom/<skill-name>.toml` and `.user.toml`.
- **Runtime variable**: a name-value pair whose value is set during workflow execution (e.g., `spec_file`, `date`, `status`).
- **Intra-skill path variable**: a variable whose value is a path to another file within the same skill — this is an anti-pattern.
- **Rendered skill**: a skill whose `SKILL.md` invokes `render_skill.py`, which renders the skill's Markdown files (entry point `workflow.md`; `SKILL.md` excluded) into an immutable snapshot before execution. Only rendered skills may use compile-time tokens. Every other skill interpolates customization values itself at runtime.
- **Rendered skill**: a skill whose `SKILL.md` invokes `render_skill.py`, which renders the skill's Markdown files (entry point `workflow.md`; `SKILL.md` excluded) into an immutable snapshot before execution. Only rendered skills may use render-time expressions. Every other skill interpolates customization values itself at runtime.
---
@@ -54,34 +54,42 @@ Path resolution differs between the last two; see PATH-01.
## Token Forms
| Form | Resolved by | Valid where |
| -------------------------------- | ------------------------------------------------------------------------------------------ | ------------------------------------------------------- |
| `{name}` | the agent, at runtime | anywhere |
| `{project-root}`, `{skill-root}` | the agent, at runtime — the project working directory and the skill's own directory; in a rendered skill `render_skill.py` binds `{skill-root}` at render time | anywhere |
| `{workflow.key}` | `render_skill.py` at render time, or the agent from `resolve_customization.py` JSON output | any skill with a `[workflow]` table in `customize.toml` |
| `{agent.key}` | the agent, from `resolve_customization.py` JSON output | agent skills |
| `{{.key}}` | `render_skill.py`, at render time | rendered skills only |
| `{{config.key}}` | `render_skill.py`, at render time | rendered skills only |
| `{{name}}` (no leading dot) | nothing — survives verbatim into the generated artifact | templates and the artifacts they seed |
| `[[bmad-snapshot:file.md]]` | `render_skill.py`, at render time | rendered skills only |
| Form | Resolved by | Valid where |
| ---------------------------------------- | ------------------------------------------------------------------------------------------ | -------------------------------------------------------------------- |
| `{name}` | the agent, at runtime | anywhere |
| `{project-root}`, `{skill-root}` | the agent, at runtime — the project working directory and the skill's own directory; in a rendered skill `render_skill.py` binds `{skill-root}` at render time | anywhere |
| `{workflow.key}` | the agent, from `resolve_customization.py` JSON output | non-rendered skills with a `[workflow]` table in `customize.toml` |
| `{agent.key}` | the agent, from `resolve_customization.py` JSON output | agent skills |
| `{{ 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 |
| `{{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 `{{.name}}` matters: the first is an artifact placeholder the consumer of the generated document fills in later; the second is a substitution baked in at render time. See REF-01 and TPL-01.
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.
### Conditional Sections in Rendered Skills
### Templates in Rendered Skills
Rendered Markdown sources can select instructions using standalone directive lines:
Rendered Markdown sources are [Jinja2](https://jinja.palletsprojects.com/) templates. `render_skill.py` renders each one with an undefined-name-halts environment, no autoescaping, the trailing newline kept, and `trim_blocks` and `lstrip_blocks` on: a `{% %}` tag on a line of its own leaves no blank line behind, and a tag that ends a line whose newline must survive closes with `+%}`.
```markdown
[[bmad-if:workflow.key == "value"]]
Instructions for this value.
[[bmad-else]]
Instructions for other values.
[[bmad-endif]]
{% if workflow.route == "oneshot" %}
Instructions for the oneshot route.
{% else %}
Instructions for the full route.
{% endif %}
{% for fact in workflow.persistent_facts %}
- {{ fact }}
{% endfor %}
```
The grammar is `[[bmad-if:<dotted customization path> <== or !=> <TOML scalar literal>]]`, an optional `[[bmad-else]]`, and a required `[[bmad-endif]]`. Blocks can nest. The path must name a scalar in the skill's `customize.toml`. Strings are quoted; a literal of a different type than the value never matches. No other operators or expressions are evaluated.
Templates see three names:
Conditions see the effective customization, including any invocation overrides. Filtering runs before token and snapshot-link resolution, so keep branch-specific links inside the matching condition. A secondary file that filters to nothing is left out of the snapshot; `workflow.md` filtering to nothing is an error.
- `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.
- `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.
Every value reached during the render is part of the generation's identity. Customization values are inserted as opaque text and never re-parsed as templates. An undefined name, a table inserted as a value, a loop over a non-list, or a syntax error halts the render with `file:line`. A secondary file whose rendered body is whitespace is left out of the snapshot, and a surviving `rendered()` link to it halts; `workflow.md` rendering to nothing halts. Agent-facing placeholders such as `{{epic_number}}` must sit inside `{% raw %}…{% endraw %}` in a rendered skill.
---
@@ -242,13 +250,13 @@ Conditions see the effective customization, including any invocation overrides.
---
### TPL-01 — Template Files Must Not Contain Compile-Time Substitutions
### TPL-01 — Template Files Must Not Contain Render-Time Expressions
- **Severity:** HIGH
- **Applies to:** `.md` files whose name contains `template` (case-insensitive)
- **Rule:** Template files become artifacts (for example spec files) that are committed and used on other machines. `render_skill.py` would replace a `{{.var}}` with a value from the rendering machine's config, and every artifact produced from the template would carry it.
- **Detection:** Regex `\{\{\.\w+\}\}` match anywhere in a file whose basename matches `/template/i`.
- **Fix:** Remove the `{{.var}}` reference. 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.
- **Rule:** Template files become artifacts (for example spec 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`.
- **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.
---
@@ -260,10 +268,10 @@ Conditions see the effective customization, including any invocation overrides.
- `{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`.
- `{{.key}}`, `{{config.key}}`, `[[bmad-snapshot:file.md]]` — only in a rendered skill (one whose SKILL.md invokes `render_skill.py`). In any other skill nothing will substitute them and they reach the agent verbatim. A `[[bmad-snapshot:file.md]]` 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.*}` and `{agent.*}` against the skill's `customize.toml`. Before flagging a compile-time token, grep the skill's `SKILL.md` for `render_skill.py` — if it is a rendered skill, the token is legitimate. Flag any token that cannot be traced to a source.
- `{{ 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.
- **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** leading dot — 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. Dotted `{{.key}}` and `{{config.key}}` are **not** covered by this exception; they are compile-time substitutions 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 }}` and `{{ workflow.key }}` 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.
+4 -4
View File
@@ -319,15 +319,15 @@ class TestRules(ProjectCase):
skill = self.valid(
"bmad-tpl",
{
"template.md": "plain {{.name}}\n```\nfenced {{.other}}\n```\n",
"notes.md": "{{.ignored}}\n",
"template.md": "plain {{ config.name }} {{placeholder}}\n```\nfenced {{workflow.other}}\n```\n",
"notes.md": "{{ config.ignored }}\n",
},
)
findings = findings_by_rule(self.findings_for(skill), "TPL-01")
self.assertEqual(len(findings), 2)
self.assertEqual({f["line"] for f in findings}, {1, 3})
self.assertTrue(any("{{.name}}" in f["detail"] for f in findings))
self.assertTrue(any("{{.other}}" in f["detail"] for f in findings))
self.assertTrue(any("{{ config.name }}" in f["detail"] for f in findings))
self.assertTrue(any("{{workflow.other}}" in f["detail"] for f in findings))
def test_read_err_on_unreadable_file_continues(self):
skill = self.valid("bmad-perm", {"secret.md": "ok\n"})
+5 -5
View File
@@ -17,7 +17,7 @@ What it checks:
- SKILL-07: SKILL.md has body content after frontmatter
- PATH-02: no installed_path variable
- SEQ-02: no time estimates
- TPL-01: template files must not contain compile-time {{.var}} substitutions
- TPL-01: template files must not contain render-time {{ config.* }} / {{ workflow.* }} expressions
Usage:
uv run --python 3.11 tools/validate_skills.py # All skills, human-readable
@@ -50,7 +50,7 @@ TIME_ESTIMATE_PATTERNS = [
re.compile(r"\bETA\b"),
]
TEMPLATE_FILENAME_REGEX = re.compile(r"template", re.I)
COMPILE_TIME_SUB_REGEX = re.compile(r"\{\{\.\w+\}\}")
COMPILE_TIME_SUB_REGEX = re.compile(r"\{\{-?\s*(?:config|workflow)\.[^}]*\}\}")
INSTALLED_PATH_RE = re.compile(r"installed_path", re.I)
USE_WHEN_RE = re.compile(r"use\s+when\b", re.I)
USE_IF_RE = re.compile(r"use\s+if\b", re.I)
@@ -502,11 +502,11 @@ def validate_skill(skill_dir: str) -> list[dict]:
findings.append(
_finding(
"TPL-01",
"Template files must not contain compile-time substitutions",
"Template files must not contain render-time expressions",
"HIGH",
rel_file,
f"Template file contains compile-time substitution `{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 `{{.var}}` reference. Use single-curly `{var}` if the value should be resolved at LLM runtime by the consumer of the generated spec.",
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.",
line=i + 1,
)
)
Generated
+88
View File
@@ -9,6 +9,7 @@ source = { virtual = "." }
[package.dev-dependencies]
dev = [
{ name = "jinja2" },
{ name = "pre-commit" },
{ name = "pytest" },
{ name = "pytest-xdist" },
@@ -21,6 +22,7 @@ dev = [
[package.metadata.requires-dev]
dev = [
{ name = "jinja2", specifier = ">=3.1" },
{ name = "pre-commit", specifier = ">=4.6.2" },
{ name = "pytest", specifier = ">=8" },
{ name = "pytest-xdist", specifier = ">=3" },
@@ -92,6 +94,92 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/08/db/fefacb2136439fc8dd20e797950e749aa1f4997ed584c62cfb8ef7c2be0e/markupsafe-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:1cc7ea17a6824959616c525620e387f6dd30fec8cb44f649e31712db02123dad", size = 11631, upload-time = "2025-09-27T18:36:18.185Z" },
{ url = "https://files.pythonhosted.org/packages/e1/2e/5898933336b61975ce9dc04decbc0a7f2fee78c30353c5efba7f2d6ff27a/markupsafe-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4bd4cd07944443f5a265608cc6aab442e4f74dff8088b0dfc8238647b8f6ae9a", size = 12058, upload-time = "2025-09-27T18:36:19.444Z" },
{ url = "https://files.pythonhosted.org/packages/1d/09/adf2df3699d87d1d8184038df46a9c80d78c0148492323f4693df54e17bb/markupsafe-3.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6b5420a1d9450023228968e7e6a9ce57f65d148ab56d2313fcd589eee96a7a50", size = 24287, upload-time = "2025-09-27T18:36:20.768Z" },
{ url = "https://files.pythonhosted.org/packages/30/ac/0273f6fcb5f42e314c6d8cd99effae6a5354604d461b8d392b5ec9530a54/markupsafe-3.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0bf2a864d67e76e5c9a34dc26ec616a66b9888e25e7b9460e1c76d3293bd9dbf", size = 22940, upload-time = "2025-09-27T18:36:22.249Z" },
{ url = "https://files.pythonhosted.org/packages/19/ae/31c1be199ef767124c042c6c3e904da327a2f7f0cd63a0337e1eca2967a8/markupsafe-3.0.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:bc51efed119bc9cfdf792cdeaa4d67e8f6fcccab66ed4bfdd6bde3e59bfcbb2f", size = 21887, upload-time = "2025-09-27T18:36:23.535Z" },
{ url = "https://files.pythonhosted.org/packages/b2/76/7edcab99d5349a4532a459e1fe64f0b0467a3365056ae550d3bcf3f79e1e/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:068f375c472b3e7acbe2d5318dea141359e6900156b5b2ba06a30b169086b91a", size = 23692, upload-time = "2025-09-27T18:36:24.823Z" },
{ url = "https://files.pythonhosted.org/packages/a4/28/6e74cdd26d7514849143d69f0bf2399f929c37dc2b31e6829fd2045b2765/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7be7b61bb172e1ed687f1754f8e7484f1c8019780f6f6b0786e76bb01c2ae115", size = 21471, upload-time = "2025-09-27T18:36:25.95Z" },
{ url = "https://files.pythonhosted.org/packages/62/7e/a145f36a5c2945673e590850a6f8014318d5577ed7e5920a4b3448e0865d/markupsafe-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f9e130248f4462aaa8e2552d547f36ddadbeaa573879158d721bbd33dfe4743a", size = 22923, upload-time = "2025-09-27T18:36:27.109Z" },
{ url = "https://files.pythonhosted.org/packages/0f/62/d9c46a7f5c9adbeeeda52f5b8d802e1094e9717705a645efc71b0913a0a8/markupsafe-3.0.3-cp311-cp311-win32.whl", hash = "sha256:0db14f5dafddbb6d9208827849fad01f1a2609380add406671a26386cdf15a19", size = 14572, upload-time = "2025-09-27T18:36:28.045Z" },
{ url = "https://files.pythonhosted.org/packages/83/8a/4414c03d3f891739326e1783338e48fb49781cc915b2e0ee052aa490d586/markupsafe-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:de8a88e63464af587c950061a5e6a67d3632e36df62b986892331d4620a35c01", size = 15077, upload-time = "2025-09-27T18:36:29.025Z" },
{ url = "https://files.pythonhosted.org/packages/35/73/893072b42e6862f319b5207adc9ae06070f095b358655f077f69a35601f0/markupsafe-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:3b562dd9e9ea93f13d53989d23a7e775fdfd1066c33494ff43f5418bc8c58a5c", size = 13876, upload-time = "2025-09-27T18:36:29.954Z" },
{ url = "https://files.pythonhosted.org/packages/5a/72/147da192e38635ada20e0a2e1a51cf8823d2119ce8883f7053879c2199b5/markupsafe-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:d53197da72cc091b024dd97249dfc7794d6a56530370992a5e1a08983ad9230e", size = 11615, upload-time = "2025-09-27T18:36:30.854Z" },
{ url = "https://files.pythonhosted.org/packages/9a/81/7e4e08678a1f98521201c3079f77db69fb552acd56067661f8c2f534a718/markupsafe-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:1872df69a4de6aead3491198eaf13810b565bdbeec3ae2dc8780f14458ec73ce", size = 12020, upload-time = "2025-09-27T18:36:31.971Z" },
{ url = "https://files.pythonhosted.org/packages/1e/2c/799f4742efc39633a1b54a92eec4082e4f815314869865d876824c257c1e/markupsafe-3.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3a7e8ae81ae39e62a41ec302f972ba6ae23a5c5396c8e60113e9066ef893da0d", size = 24332, upload-time = "2025-09-27T18:36:32.813Z" },
{ url = "https://files.pythonhosted.org/packages/3c/2e/8d0c2ab90a8c1d9a24f0399058ab8519a3279d1bd4289511d74e909f060e/markupsafe-3.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d6dd0be5b5b189d31db7cda48b91d7e0a9795f31430b7f271219ab30f1d3ac9d", size = 22947, upload-time = "2025-09-27T18:36:33.86Z" },
{ url = "https://files.pythonhosted.org/packages/2c/54/887f3092a85238093a0b2154bd629c89444f395618842e8b0c41783898ea/markupsafe-3.0.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:94c6f0bb423f739146aec64595853541634bde58b2135f27f61c1ffd1cd4d16a", size = 21962, upload-time = "2025-09-27T18:36:35.099Z" },
{ url = "https://files.pythonhosted.org/packages/c9/2f/336b8c7b6f4a4d95e91119dc8521402461b74a485558d8f238a68312f11c/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:be8813b57049a7dc738189df53d69395eba14fb99345e0a5994914a3864c8a4b", size = 23760, upload-time = "2025-09-27T18:36:36.001Z" },
{ url = "https://files.pythonhosted.org/packages/32/43/67935f2b7e4982ffb50a4d169b724d74b62a3964bc1a9a527f5ac4f1ee2b/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:83891d0e9fb81a825d9a6d61e3f07550ca70a076484292a70fde82c4b807286f", size = 21529, upload-time = "2025-09-27T18:36:36.906Z" },
{ url = "https://files.pythonhosted.org/packages/89/e0/4486f11e51bbba8b0c041098859e869e304d1c261e59244baa3d295d47b7/markupsafe-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:77f0643abe7495da77fb436f50f8dab76dbc6e5fd25d39589a0f1fe6548bfa2b", size = 23015, upload-time = "2025-09-27T18:36:37.868Z" },
{ url = "https://files.pythonhosted.org/packages/2f/e1/78ee7a023dac597a5825441ebd17170785a9dab23de95d2c7508ade94e0e/markupsafe-3.0.3-cp312-cp312-win32.whl", hash = "sha256:d88b440e37a16e651bda4c7c2b930eb586fd15ca7406cb39e211fcff3bf3017d", size = 14540, upload-time = "2025-09-27T18:36:38.761Z" },
{ url = "https://files.pythonhosted.org/packages/aa/5b/bec5aa9bbbb2c946ca2733ef9c4ca91c91b6a24580193e891b5f7dbe8e1e/markupsafe-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:26a5784ded40c9e318cfc2bdb30fe164bdb8665ded9cd64d500a34fb42067b1c", size = 15105, upload-time = "2025-09-27T18:36:39.701Z" },
{ url = "https://files.pythonhosted.org/packages/e5/f1/216fc1bbfd74011693a4fd837e7026152e89c4bcf3e77b6692fba9923123/markupsafe-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:35add3b638a5d900e807944a078b51922212fb3dedb01633a8defc4b01a3c85f", size = 13906, upload-time = "2025-09-27T18:36:40.689Z" },
{ url = "https://files.pythonhosted.org/packages/38/2f/907b9c7bbba283e68f20259574b13d005c121a0fa4c175f9bed27c4597ff/markupsafe-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:e1cf1972137e83c5d4c136c43ced9ac51d0e124706ee1c8aa8532c1287fa8795", size = 11622, upload-time = "2025-09-27T18:36:41.777Z" },
{ url = "https://files.pythonhosted.org/packages/9c/d9/5f7756922cdd676869eca1c4e3c0cd0df60ed30199ffd775e319089cb3ed/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:116bb52f642a37c115f517494ea5feb03889e04df47eeff5b130b1808ce7c219", size = 12029, upload-time = "2025-09-27T18:36:43.257Z" },
{ url = "https://files.pythonhosted.org/packages/00/07/575a68c754943058c78f30db02ee03a64b3c638586fba6a6dd56830b30a3/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:133a43e73a802c5562be9bbcd03d090aa5a1fe899db609c29e8c8d815c5f6de6", size = 24374, upload-time = "2025-09-27T18:36:44.508Z" },
{ url = "https://files.pythonhosted.org/packages/a9/21/9b05698b46f218fc0e118e1f8168395c65c8a2c750ae2bab54fc4bd4e0e8/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccfcd093f13f0f0b7fdd0f198b90053bf7b2f02a3927a30e63f3ccc9df56b676", size = 22980, upload-time = "2025-09-27T18:36:45.385Z" },
{ url = "https://files.pythonhosted.org/packages/7f/71/544260864f893f18b6827315b988c146b559391e6e7e8f7252839b1b846a/markupsafe-3.0.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:509fa21c6deb7a7a273d629cf5ec029bc209d1a51178615ddf718f5918992ab9", size = 21990, upload-time = "2025-09-27T18:36:46.916Z" },
{ url = "https://files.pythonhosted.org/packages/c2/28/b50fc2f74d1ad761af2f5dcce7492648b983d00a65b8c0e0cb457c82ebbe/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a4afe79fb3de0b7097d81da19090f4df4f8d3a2b3adaa8764138aac2e44f3af1", size = 23784, upload-time = "2025-09-27T18:36:47.884Z" },
{ url = "https://files.pythonhosted.org/packages/ed/76/104b2aa106a208da8b17a2fb72e033a5a9d7073c68f7e508b94916ed47a9/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:795e7751525cae078558e679d646ae45574b47ed6e7771863fcc079a6171a0fc", size = 21588, upload-time = "2025-09-27T18:36:48.82Z" },
{ url = "https://files.pythonhosted.org/packages/b5/99/16a5eb2d140087ebd97180d95249b00a03aa87e29cc224056274f2e45fd6/markupsafe-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:8485f406a96febb5140bfeca44a73e3ce5116b2501ac54fe953e488fb1d03b12", size = 23041, upload-time = "2025-09-27T18:36:49.797Z" },
{ url = "https://files.pythonhosted.org/packages/19/bc/e7140ed90c5d61d77cea142eed9f9c303f4c4806f60a1044c13e3f1471d0/markupsafe-3.0.3-cp313-cp313-win32.whl", hash = "sha256:bdd37121970bfd8be76c5fb069c7751683bdf373db1ed6c010162b2a130248ed", size = 14543, upload-time = "2025-09-27T18:36:51.584Z" },
{ url = "https://files.pythonhosted.org/packages/05/73/c4abe620b841b6b791f2edc248f556900667a5a1cf023a6646967ae98335/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:9a1abfdc021a164803f4d485104931fb8f8c1efd55bc6b748d2f5774e78b62c5", size = 15113, upload-time = "2025-09-27T18:36:52.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/3a/fa34a0f7cfef23cf9500d68cb7c32dd64ffd58a12b09225fb03dd37d5b80/markupsafe-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:7e68f88e5b8799aa49c85cd116c932a1ac15caaa3f5db09087854d218359e485", size = 13911, upload-time = "2025-09-27T18:36:53.513Z" },
{ url = "https://files.pythonhosted.org/packages/e4/d7/e05cd7efe43a88a17a37b3ae96e79a19e846f3f456fe79c57ca61356ef01/markupsafe-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:218551f6df4868a8d527e3062d0fb968682fe92054e89978594c28e642c43a73", size = 11658, upload-time = "2025-09-27T18:36:54.819Z" },
{ url = "https://files.pythonhosted.org/packages/99/9e/e412117548182ce2148bdeacdda3bb494260c0b0184360fe0d56389b523b/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3524b778fe5cfb3452a09d31e7b5adefeea8c5be1d43c4f810ba09f2ceb29d37", size = 12066, upload-time = "2025-09-27T18:36:55.714Z" },
{ url = "https://files.pythonhosted.org/packages/bc/e6/fa0ffcda717ef64a5108eaa7b4f5ed28d56122c9a6d70ab8b72f9f715c80/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e885a3d1efa2eadc93c894a21770e4bc67899e3543680313b09f139e149ab19", size = 25639, upload-time = "2025-09-27T18:36:56.908Z" },
{ url = "https://files.pythonhosted.org/packages/96/ec/2102e881fe9d25fc16cb4b25d5f5cde50970967ffa5dddafdb771237062d/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8709b08f4a89aa7586de0aadc8da56180242ee0ada3999749b183aa23df95025", size = 23569, upload-time = "2025-09-27T18:36:57.913Z" },
{ url = "https://files.pythonhosted.org/packages/4b/30/6f2fce1f1f205fc9323255b216ca8a235b15860c34b6798f810f05828e32/markupsafe-3.0.3-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:b8512a91625c9b3da6f127803b166b629725e68af71f8184ae7e7d54686a56d6", size = 23284, upload-time = "2025-09-27T18:36:58.833Z" },
{ url = "https://files.pythonhosted.org/packages/58/47/4a0ccea4ab9f5dcb6f79c0236d954acb382202721e704223a8aafa38b5c8/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:9b79b7a16f7fedff2495d684f2b59b0457c3b493778c9eed31111be64d58279f", size = 24801, upload-time = "2025-09-27T18:36:59.739Z" },
{ url = "https://files.pythonhosted.org/packages/6a/70/3780e9b72180b6fecb83a4814d84c3bf4b4ae4bf0b19c27196104149734c/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:12c63dfb4a98206f045aa9563db46507995f7ef6d83b2f68eda65c307c6829eb", size = 22769, upload-time = "2025-09-27T18:37:00.719Z" },
{ url = "https://files.pythonhosted.org/packages/98/c5/c03c7f4125180fc215220c035beac6b9cb684bc7a067c84fc69414d315f5/markupsafe-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:8f71bc33915be5186016f675cd83a1e08523649b0e33efdb898db577ef5bb009", size = 23642, upload-time = "2025-09-27T18:37:01.673Z" },
{ url = "https://files.pythonhosted.org/packages/80/d6/2d1b89f6ca4bff1036499b1e29a1d02d282259f3681540e16563f27ebc23/markupsafe-3.0.3-cp313-cp313t-win32.whl", hash = "sha256:69c0b73548bc525c8cb9a251cddf1931d1db4d2258e9599c28c07ef3580ef354", size = 14612, upload-time = "2025-09-27T18:37:02.639Z" },
{ url = "https://files.pythonhosted.org/packages/2b/98/e48a4bfba0a0ffcf9925fe2d69240bfaa19c6f7507b8cd09c70684a53c1e/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:1b4b79e8ebf6b55351f0d91fe80f893b4743f104bff22e90697db1590e47a218", size = 15200, upload-time = "2025-09-27T18:37:03.582Z" },
{ url = "https://files.pythonhosted.org/packages/0e/72/e3cc540f351f316e9ed0f092757459afbc595824ca724cbc5a5d4263713f/markupsafe-3.0.3-cp313-cp313t-win_arm64.whl", hash = "sha256:ad2cf8aa28b8c020ab2fc8287b0f823d0a7d8630784c31e9ee5edea20f406287", size = 13973, upload-time = "2025-09-27T18:37:04.929Z" },
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "nodeenv"
version = "1.10.0"