bmad-code-org/bmad-methodContrôle réussi
SKILL DETAIL
bmad-preview-ticketing
bmad-code-org/bmad-method/bmad-preview-ticketing
Create and manage tickets at every level — slice an initiative into epics, break an epic into stories, write or refine a ticket, and run the board (publish, ready, move, assign, status, cancel). Use when the user says "Create a new initiative", "slice this", "incept this epic", "make a ticket", "refine this ticket", "what's ready", "status of a story", "publish ticket changes".
Installations · 159Voir la source
Installation
npx skills add https://github.com/bmad-code-org/bmad-method --skill bmad-preview-ticketing
Fichiers du skill
SKILL.md
Dernière synchronisation · 21 sept. 2026
assets/bug-template.md›
---
id: "" # set at publish
remote: "" # the store url, for a tracker
type: bug
title: "[What is wrong, from the user's view]"
parent: [folder name of the epic, or of the initiative when there are no epics; none for a standalone bug in backlog/]
covers: []
blocked_by: []
blocked_at: "" # date, when waiting on a person or an answer
blocked_reason: ""
assignee: ""
status: draft
refined: false # true once refined and approved
hitl: false
risk: [low|medium|high]
severity: [P0|P1|P2|P3]
estimate: "" # points, when estimation is on
---
# [Title]
## Description
[What the user sees go wrong and what should happen instead, from their side. 2–4 sentences.]
## Reproduction
[Exact steps, environment, and what happens versus what should happen. Logs or errors verbatim.]
## Cause Hypothesis
[Where the defect likely lives and why — a hypothesis, never a prescribed fix.]
## Acceptance Criteria
1. **[The expected behavior holds]**
**Given** [the state from the reproduction]
**When** [the reproduction steps are followed]
**Then** [what should happen, as it should now happen]
2. **Tests cover the condition found and fixed**
**Given** the test suite
**When** it runs
**Then** a test that fails on the defect and passes on the fix covers the reproduction, and one covers each related case the fix touched
3. **Or: no change is needed, with proof**
**Given** the reproduction
**When** it is run on the current code
**Then** the expected behavior already holds, or the report was mistaken, with the evidence recorded in Notes — this supersedes 1 and 2
## References
- parent — [path or url, or none]
- [source — path, section]
- [logs, screenshots, or sample data — location]
## Notes
[Only what is not in the repo or the source, and what is not settled. Cut if empty.]
- Decision: [a choice the user made, dated]
- Assumption: [a choice made while drafting that the user has not confirmed]
- Open question: [what is not settled; answering it is part of the ticket's work]
<!-- Example, not part of the ticket: match its level of detail. What is good here: a reproduction someone else can follow, with the actual and expected values; a cause hypothesis that is not a fix; one criterion for the behavior, one for the tests that cover the condition found and fixed, and one that supersedes both when the reproduction shows no change is needed. -->
```markdown
---
id: ""
remote: ""
type: bug
title: "Checkout total ignores an applied discount code after the shopper changes quantity"
parent: none
covers: []
blocked_by: []
blocked_at: ""
blocked_reason: ""
assignee: ""
status: draft
refined: true
hitl: false
risk: medium
severity: P2
---
# Checkout total ignores an applied discount code after the shopper changes quantity
## Description
A shopper applies a valid code, then changes an item's quantity, and the total goes back to full price while the discount line still shows the code. They are charged full price at payment.
## Reproduction
1. Production, Chrome 130, logged out. Add "Canvas tote" (29.00) ×1 to the cart.
2. Apply SAVE10. Total shows 26.10, discount line shows SAVE10 −2.90.
3. Change quantity to 2.
4. Actual: total 58.00, discount line still SAVE10 −2.90. Expected: 52.20, discount line SAVE10 −5.80.
5. Continue to payment: amount charged is 58.00.
Log line at step 3: `pricing.recompute cart=… discount=null`.
## Cause Hypothesis
The quantity-change path recomputes the total without passing the applied code, so the discount is dropped while the UI keeps the stale line. Likely in the recompute call, not in the discount engine, since step 2 is right.
## Acceptance Criteria
1. **The expected behavior holds**
**Given** a cart with a valid code applied
**When** the shopper changes an item's quantity
**Then** the total recomputes with the code still applied and the discount line shows the new amount
2. **Tests cover the condition found and fixed**
**Given** the pricing test suite
**When** it runs
**Then** a test that fails on the defect and passes on the fix covers a quantity change with a code applied, and one covers removing an item with a code applied
3. **Or: no change is needed, with proof**
**Given** the reproduction
**When** it is run on the current code
**Then** the total already recomputes with the code applied, or the report was mistaken, with the evidence recorded in Notes — this supersedes 1 and 2
## References
- parent — none
- logs — support ticket #4471, attachment pricing.log
```
assets/epic-template.md›
---
id: "" # set at publish
remote: "" # the store url, for a tracker
type: epic
title: "[The outcome this container exists to reach]"
parent: [folder name of the initiative]
covers: [parent requirement ids this epic owns; keep these when adding an epic-local spec]
blocked_by: []
assignee: ""
status: draft
risk: [low|medium|high — the highest expected among its children]
estimate: "" # t-shirt, when estimation is on
estimate_basis: "" # envelope | spec | drafts | stories
---
<!-- At initiative slicing, the envelope: frontmatter, Description, Outcome, Done when, Boundaries, References, and known Notes. Requirements and Breakdown are completed at this epic's inception. -->
# [Title]
## Description
[What is true when this is done and why it matters. With a spec, one paragraph pointing at it; without one, the intent in full.]
## Outcome
[One sentence: for whom, what changes, and the signal that shows it worked — the spec's, named not restated, when there is one.]
## Requirements
[The requirement source at this altitude: the source's lines as stable ids for children to cite, each mapping to a parent id in covers. Reuse the parent's ids when the lines are the parent's; when the epic splits or adds one, mint `E<n> (R<m>)` so the map is on the line. An epic with no parent ids, such as the platform baseline, leaves covers empty and cites the source section on each line instead. A referenced numbered source replaces it; a separate spec only when the source outgrows this section.]
## Done when
[Three to six checks a person can run without opening a child — the measures, limits, and behaviors from the source. Each fails today. Closing every child is not one.]
## Breakdown
[At inception: every agreed entry in build order, one line each, exactly `- nn type — title; blocked_by: nn, nn; covers: ids`. tickets.py reads these lines; a table or any other shape is invisible to it. Anything more about an entry goes in Notes or waits for its file. An entry becomes a file with the same nn when pulled; its line stays. Status lives on the files, never here. Cut at initiative slicing.]
## Boundaries
[Which boundary this container follows — team, service, UI, capability — and what it is not. Point at the spec's non-goals when there is one.]
## References
[`type — location, section`. The spec at this level when one exists (its references are followed from there); otherwise what this was split from.]
- parent — [path or url, section containing the upstream requirement ids]
- spec — [local spec when present; its capability ids map back to parent ids]
- constraint — [the source section that binds this container: privacy, platform, licensing, performance]
- [an input the spec does not carry — location, section]
## Notes
[What is not settled at this level. Cut if empty.]
- Assumption: [a choice made while slicing that the user has not confirmed]
- Open question: [what the source does not settle and which children wait on it]
- Unknown: [what will likely need a spike, known now so it is not found late]
- Parked: [a requirement id not placed on any child, and why, with the user's knowledge]
- Decision: [a choice the user made, dated, so it is not asked again — a declined suggestion belongs here too]
- Blocked by [id] because: [the one-line reason for each entry in blocked_by]
<!-- Example, not part of the ticket: an incepted epic with no spec of its own. The parent assigned R1–R4 to this epic from an unnumbered PRD; Requirements records those lines using the same ids. Done when holds deliverable checks. Breakdown holds the agreed entries; the files exist only for those pulled. Notes holds decisions and unknowns. -->
```markdown
---
id: ""
remote: ""
type: epic
title: "Shoppers manage their cart"
parent: initiative-checkout
covers: [R1, R2, R3, R4]
blocked_by: []
assignee: ""
status: draft
risk: medium
---
# Shoppers manage their cart
## Description
A shopper adds items, changes quantities, applies discount codes, and recovers from every refusal with a reason — and the total they see is always the one the payment step receives.
## Outcome
Shoppers who reach the cart continue to payment more often because the total never surprises them; the PRD's cart-to-payment completion measure is the signal.
## Requirements
- R1: Add, remove, and change quantity of any item; the total updates at once. (PRD, Capabilities, Cart)
- R2: Apply one discount code; refused codes say why. (PRD, Capabilities, Cart)
- R3: The total shown is the total charged, before tax. (PRD, Capabilities, Cart)
- R4: Cart actions respond within 300 ms on the slowest supported device. (PRD, Constraints)
## Done when
1. R1–R3 work end to end on the live site, refusals included, with the reason shown.
2. The end-to-end suite proves the shown total equals the amount sent to payment on every cart path.
3. R4 holds on the slowest supported device under the load test.
4. A shopper with no code applied sees no change from today's cart.
## Breakdown
- 01 story — Cart service scaffold; covers: R1
- 02 story — Cart UI shell; blocked_by: 01; covers: R1
- 03 spike — Can the discount engine validate within R4?; blocked_by: 01; covers: R4
- 04 story — Apply and refuse discount codes; blocked_by: 02, 03; covers: R2, R3
- 05 story — Total shown equals total charged, end to end; blocked_by: 04; covers: R3
## Boundaries
The cart UI. Not the pricing service (epic Pricing rules), not tax (epic Tax and payment).
## References
- prd — _bmad-output/initiative-checkout/prd-checkout-2026-07-02/prd.md, sections Capabilities and Constraints
- design — https://figma.com/design/ab12cd/checkout, frame Cart
## Notes
- Decision: codes are case-insensitive (user's decision, 2026-08-12).
- Unknown: whether the discount engine can validate a code within R4; a spike if not.
```
assets/initiative-template.md›
---
id: "" # set at publish
remote: "" # the store url, for a tracker
type: initiative
title: "[The outcome this container exists to reach]"
parent: none
covers: [ids from the spec at this level, or from Requirements below when the source has none]
blocked_by: []
assignee: ""
status: draft
risk: [low|medium|high — the highest expected among its children]
estimate: "" # t-shirt, when estimation is on
estimate_basis: "" # envelope | spec | drafts | stories
---
# [Title]
## Description
[What is true when this is done and why it matters. With a spec, one paragraph pointing at it; without one, the intent in full.]
## Outcome
[One sentence: for whom, what changes, and the signal that shows it worked — the spec's, named not restated, when there is one.]
## Requirements
[The requirement source at this altitude: the source's lines as stable ids for children to cite, each mapping to a source id in covers. A referenced numbered source replaces it; a separate spec only when the source outgrows this section.]
## Done when
[Three to six checks a person can run without opening a child — the measures, limits, and behaviors from the source. Each fails today. Closing every child is not one.]
## Breakdown
[At slicing: every epic in recommended build order, one line each, exactly `- epic-<slug> — title; after: epic-<slug> (what it needs from it); covers: ids`; `after` and `covers` are optional. No table. `after` names a partial dependency; `blocked_by` on the epic is for a whole-epic gate only. The tracer path across epics is one sentence below the list. Status lives on the epic files, never here.]
## Boundaries
[Which boundary this container follows — team, service, UI, capability — and what it is not. Point at the spec's non-goals when there is one.]
## References
[`type — location, section`. The spec at this level when one exists (its references are followed from there); otherwise what this was split from.]
- spec — [path from {project-root}, or url for a remote source; section Capabilities]
- constraint — [the source section that binds this container: privacy, platform, licensing, performance]
- [an input the spec does not carry — location, section]
## Notes
[What is not settled at this level. Cut if empty.]
- Assumption: [a choice made while slicing that the user has not confirmed]
- Open question: [what the source does not settle and which children wait on it]
- Unknown: [what will likely need a spike, known now so it is not found late]
- Parked: [a requirement id not placed on any child, and why, with the user's knowledge]
- Decision: [a choice the user made, dated, so it is not asked again — a declined suggestion belongs here too]
- Blocked by [id] because: [the one-line reason for each entry in blocked_by]
<!-- An initiative is the business outcome its epics serve, tied to a company goal, usually spanning quarters and more than one boundary. For a solo developer or small team with no larger goal above it, a whole product is a fine initiative.
Example, not part of the ticket: match its level of detail. This one keeps a separate spec because its source outgrew the section: Description points at it, covers cites its ids, Requirements is cut, Outcome names its signal. Done when reads as business outcomes a product owner checks at the end, not deliverables. Breakdown is the epic order with what each needs from the one before. Notes holds the parked capability and the decision. -->
```markdown
---
id: ""
remote: ""
type: initiative
title: "Checkout that shoppers finish"
parent: none
covers: [C1, C2, C3, C4, C5, C6, P1, P2, T1]
blocked_by: []
assignee: ""
status: draft
risk: high
---
# Checkout that shoppers finish
## Description
Shoppers go from cart to paid order in one pass, with the price they saw, on any supported device or as a guest. The spec owns the capabilities, constraints, and non-goals; this initiative delivers them for the fall release.
## Outcome
The Q4 revenue target depends on lifting cart-to-payment completion from 55% to 70%; this initiative owns that number, the spec's success signal, measured over a full quarter.
## Done when
1. Cart-to-payment completion holds at the spec's target for one full quarter after release.
2. Every capability in C1–C6, P1–P2, and T1 is live for all shoppers, not behind a flag.
3. Refund and chargeback rates are no worse than the quarter before release.
4. No P0 or P1 checkout bug open for more than a day during the first month.
## Breakdown
- epic-pricing-rules — Pricing rules; covers: P1, P2
- epic-cart-rules — Shoppers manage their cart; after: epic-pricing-rules (the one-function pricing contract); covers: C1, C2, C3
- epic-tax-and-payment — Tax and payment; after: epic-cart-rules (the cart total contract); covers: C4, C5, T1
- epic-guest-checkout — Guest checkout; after: epic-tax-and-payment (a paid order end to end); covers: C6
## Boundaries
The web store's checkout flow, the payment-provider integration, and guest checkout across web and mobile. Not the order-management backend beyond the order it creates, not loyalty, not the catalog; see the spec's non-goals.
## References
- spec — _bmad-output/initiative-checkout/spec-checkout/spec-checkout.md
- constraint — the same spec, section Constraints, PCI scope and response time
- prd — _bmad-output/initiative-checkout/prd-checkout-2026-07-02/prd.md, for history only
## Notes
- Parked: gift cards (C7); not in the fall release, user's call 2026-08-12.
- Decision: one payment provider for v1, replacing the current one (user's decision, 2026-08-12).
- Unknown: whether the tax service can meet the response-time constraint at peak; epic Tax and payment owns the spike.
```
assets/spike-template.md›
---
id: "" # set at publish
remote: "" # the store url, for a tracker
type: spike
title: "[The question this answers]"
parent: [folder name of the epic, or of the initiative when there are no epics; none for a standalone ticket in backlog/]
covers: []
blocked_by: []
blocked_at: "" # date, when waiting on a person or an answer
blocked_reason: ""
assignee: ""
status: draft
refined: false # true once refined and approved
hitl: true
risk: [low|medium|high]
estimate: "" # points, when estimation is on
---
# [Title]
## Description
[Thin: one sentence, the question. Refined: the unknown this resolves — usually a placeholder left at design time — and which tickets wait on the answer, 2–4 sentences.]
## Approach
[The steps, as many as the question needs: what to research, what to design, what to prototype, who signs off. The time box. What a good-enough answer looks like.]
## Acceptance Criteria
1. **[The candidates are compared on the criteria that decide]**
Verify: [where the comparison is recorded]
2. **[The prototype shows the chosen answer works for the cases that matter]**
Verify: [where the prototype and its results live]
3. **[The answer is recorded, with its consequences, where the waiting tickets can find it]**
Verify: [where]
4. **[The people who must accept it have]**
Verify: [who, recorded where]
## References
- parent — [path or url]
- [source — path, section]
## Notes
[Assumptions and open questions about the spike itself, each marked. Cut if empty.]
<!-- Example, not part of the ticket: match its level of detail. What is good here: the unknown was a placeholder in the architecture; the spike names who waits, runs research, design, prototype, and sign-off in a time box, says what good enough is, and records the answer where the stories will find it. hitl because people accept the finding. -->
```markdown
---
id: ""
remote: ""
type: spike
title: "How do field devices merge conflicting observations after days offline?"
parent: epic-field-sync
covers: []
blocked_by: []
blocked_at: ""
blocked_reason: ""
assignee: ""
status: draft
refined: true
hitl: true
risk: low
---
# How do field devices merge conflicting observations after days offline?
## Description
The architecture left sync as a placeholder: "devices reconcile on reconnect." Researchers in the field edit the same observation records on several tablets for days without a link, then sync over a slow satellite connection. Three planned stories in this epic — edit observations offline, sync on reconnect, show merge results to the team lead — cannot be refined for implementation until we know how conflicting edits merge, what that does to the data model, and whether the link can carry it.
## Approach
Time box: four days. At the end, the best answer so far is recorded, not a perfect one.
1. Research: compare three ways to merge — a CRDT library, last-writer-wins per field with a conflict log, and a custom merge on the observation schema — on the criteria that decide: correctness on the six conflict cases the field team listed, payload size over a 2 kbps link, library maturity, and how much of the data model each forces us to change.
2. Design: a one-page note on the chosen approach, its data-model consequences, and the cases it does not resolve automatically.
3. Prototype: two tablets and a simulated link; run the six conflict cases and measure sync payload and time.
4. Sign-off: the lead engineer accepts the design; the field science lead accepts how unresolved conflicts are shown to people.
Good enough: all six cases merge as the field team expects, or the exceptions are listed with how a person resolves them, and a full day's edits sync in under ten minutes on the simulated link.
## Acceptance Criteria
1. **The three approaches are compared on the deciding criteria**
Verify: the comparison table is in epic-field-sync/spike-04-offline-merge-comparison.md
2. **The prototype merges the six conflict cases on a simulated link**
Verify: the prototype and its run log are under epic-field-sync/spike-04-offline-merge-prototype/, with payload and time per case
3. **The answer and its data-model consequences are recorded where the stories will find them**
Verify: the design note is epic-field-sync/spike-04-offline-merge-design.md and the choice is in epic-field-sync.md Notes as a decision
4. **The lead engineer and the field science lead have accepted it**
Verify: both named in that decision line with the date
## References
- parent — epic-field-sync/epic-field-sync.md
- architecture — docs/architecture.md, section Sync (the placeholder)
- field team conflict cases — docs/research/conflict-cases.md
## Notes
- Assumption: the satellite link is never better than 2 kbps up; confirm with the operations lead.
- Open question: is the conflict log kept forever, or pruned after the lead resolves it? The field science lead answers.
```
assets/story-template.md›
---
id: "" # set at publish
remote: "" # the store url, for a tracker
type: story
title: "[What exists or works when this is done]"
parent: [folder name of the epic, or of the initiative when there are no epics; none for a standalone ticket in backlog/]
covers: [ids from the epic's spec, referenced numbered source, or Requirements; this ticket's contribution]
blocked_by: [] # sibling numbers, file names, or ids
blocked_at: "" # date, when waiting on a person or an answer
blocked_reason: ""
assignee: ""
status: draft
refined: false # true once refined and approved
hitl: false
risk: [low|medium|high]
estimate: "" # points, when estimation is on
---
<!-- Thin: Description as the contribution, Acceptance Criteria as one Verify: line, References, local Notes. Refined: full criteria and Boundaries. -->
# [Title]
## Description
[Thin: one sentence, the contribution. Refined: what exists or works when this is done and how it advances the epic, 2–4 sentences; the criteria below carry the proof, do not restate them.]
## Acceptance Criteria
[Thin: one line, `Verify: how the contribution will be checked`, nothing else. Refined: the numbered criteria below.]
1. **[Short name of the behavior]**
**Given** [the state before: data, user, config]
**When** [the one action]
**Then** [what is observed: a response, a record, a screen — something a person or a test can check]
**And** [a further observation from the same action, when there is one]
2. **[Short name of the behavior]**
**Given** [...]
**When** [...]
**Then** [...]
## Boundaries
- Must not change: [adjacent behavior a valid implementation could damage — name behavior, not files]
## References
- parent — [path or url]
- [source document — path, section]
- [design or prototype — path, for UI work]
## Notes
[Only what is not in the repo or the source, and what is not settled. Cut if empty.]
- [A frozen interface, a declined option]
- Decision: [a choice the user made, dated]
- Assumption: [a choice made while drafting that the user has not confirmed; confirmed, it becomes a Decision line]
- Open question: [what only this ticket waits on. Touches siblings: the parent's Notes. Gates work: a spike.]
<!-- Example, not part of the ticket: match its level of detail. What is good here: the Description is what the shopper can do, end to end; every criterion states a rule, not an instance, with its failure path, fails today and passes only through this work; Boundaries names behavior, not files; References points at the nearest document; Notes holds only what is not in the repo or the source, plus one assumption for the user to confirm. -->
```markdown
---
id: ""
remote: ""
type: story
title: "A shopper applies a discount code and sees the new total"
parent: epic-cart-rules
covers: [R2, R3]
blocked_by: [spike-03-discount-engine-latency]
blocked_at: ""
blocked_reason: ""
assignee: ""
status: draft
refined: true
hitl: false
risk: medium
---
# A shopper applies a discount code and sees the new total
## Description
A shopper with items in the cart enters a discount code, and the cart total updates to show the discount before tax. An invalid or expired code tells them why it was refused and leaves the total as it was.
## Acceptance Criteria
1. **Valid code reduces the total**
**Given** a cart and a valid discount code
**When** the shopper applies it
**Then** the total drops by the code's value and a discount line shows the code and the amount
2. **Expired code is refused with the reason**
**Given** a cart and an expired code
**When** the shopper applies it
**Then** the total is unchanged and the message says the code expired and when
3. **Unknown code is refused without revealing valid codes**
**Given** a cart and a code that does not exist
**When** the shopper applies it
**Then** the total is unchanged and the message says the code is not recognized, nothing more
4. **An applied code survives a quantity change**
**Given** an applied code
**When** the shopper changes an item's quantity
**Then** the total recomputes with the code still applied
5. **Payment receives the discounted total**
**Given** an applied code
**When** the shopper continues to payment
**Then** the amount sent to payment equals the total shown
## Boundaries
- Must not change: catalog prices, tax computation, the cart for shoppers who apply no code.
## References
- parent — _bmad-output/initiative-checkout/epic-cart-rules/epic-cart-rules.md, Requirements R2 and R3
- design — https://figma.com/design/ab12cd/checkout, frame Cart
## Notes
- Decision: the discount engine's `validate(code, cart) -> {amount, reason}` interface is frozen (2026-08-12).
- Assumption: the refusal messages above are final copy; no design text exists for them.
```
config/gh-ticketing.toml›
# Ticketing config: GitHub Issues. Read by every skill that creates, reads, or moves tickets.
# A starter: at first use the skill copies the chosen one to _bmad/custom/ticketing-store-config.toml
# (references/store-setup.md), and that copy is the one edited. An org can instead host its own starter
# anywhere — a repo path or a url — and setup copies from it. Skills read
# [tickets] and [verbs] through a script, so comments are never loaded.
# Every ticketing config has the same fields; only the prose and the maps differ. `description` sits
# outside [tickets] so it is never loaded; it is for choosing a config.
# One tool: the gh CLI, 2.94 or later (sub-issues and dependencies). Labels carry type and status,
# sub-issues carry hierarchy, blocked-by relations carry blocking, milestones put initiatives on the roadmap.
# Using the defaults below: the repo needs the type, status, and hitl labels; `setup` creates them.
#
# The active initiative is `modules.bmm.active_initiative` in _bmad/custom/config.user.toml, read at activation. The ticket tree under root
# has the same layout for every store (layout in references/board.md, Layout); write pushes it to this tracker.
# An empty field below that an operation needs, and that cannot be inferred from what the user gave:
# use what they tell you for this run and offer to record it in this file (references/store-setup.md).
description = "GitHub Issues through the gh CLI: labels, sub-issues, blocked-by, milestones. For teams whose code and planning already live on GitHub."
[tickets]
store = "github"
root = "{output_folder}"
key = "" # ids are the repo's issue numbers; leave empty
repo = "" # OWNER/REPO; empty = the repo of the current checkout (origin)
access = """
Use the gh CLI against `repo`, passing `-R <repo>` on every call when it is set. If a call fails
on auth or a missing label, tell the user what is blocking, confirm the setup with them, and ask them to authenticate when that is the cause.
"""
fields = """
risk is a label risk:<value>; severity is a label P0-P3. `setup` creates them with the other
labels; publish and update set them like any label. estimate is a label est:<value> (`setup` creates none; add on first use). Empty field: no label.
"""
reference = """
A reference is a line in the ticket's References section: `type — location, section`, location a
path from {project-root} or a url for a remote source, opened before it is cited. GitHub renders urls as links; nothing more to do.
"""
# Verb prose lives in its own table so a skill can load one verb at a time.
[verbs]
setup = """
Connect: `gh auth login`.
Labels must exist before they are used. Run `gh label create <name> --force` for every value in
[tickets.types], the label values in [tickets.status], `hitl`, risk:low, risk:medium, risk:high,
and P0-P3. Offer this at first publish, or
whenever a label is missing.
"""
write = """
One issue per new ticket, created in tree and blocking order so every relation has a number to point at.
Create: `gh issue create --title <t> --body-file <b> --label <type>,<[tickets.status].backlog>[,hitl,risk:<r>,P<n>]
--parent <n> --blocked-by <n,n> [--milestone <initiative>]`. Changes go through `gh issue edit <n>`
(--body-file, --add-assignee, --add-blocked-by, label swaps; --add-sub-issue on the parent). No
parent: omit --parent; no initiative: omit --milestone. Needs
gh 2.94+; check `--help` when a flag is doubted.
Status is a label swap on an open issue. done: remove the status label and `gh issue close <n>`;
dropped: the same with `--reason "not planned"`. An initiative is a
milestone (`gh api repos/{owner}/{repo}/milestones -f title=<name>`) holding its tree's issues.
The body sent is the local file without the frontmatter lines id, remote, status, assignee; build
notes belong in <ticket filename>-plan.md beside it, posted as a comment on the move to
review (or done when there is no review).
After a write, mirror id (the number), remote (the url), status, assignee, and blocked_by in the
local file; commit local files on the current branch when root is in a git repo, never push.
"""
query = """
One issue: `gh issue view <n> --json
number,title,labels,assignees,state,stateReason,milestone,parent,blockedBy,subIssuesSummary,body`.
Type, hitl, risk, severity, and status are labels — except done and dropped, the closed states per
[tickets.status]; the body's covers holds the source requirement ids. Children: `gh issue view <n>
--json subIssues`. Words: `gh issue list --search "<words>" --state all`, narrowed by
--label. The local file: `grep -rl "^id: <n>" root` or the filename.
A child the tracker holds with no file in the tree gets one, numbered after the last.
Candidates to pull (refine thin tickets before starting): open (in the initiative's milestone, or with no milestone for a standalone ticket), backlog label and no other status label, every
blockedBy closed as completed, no assignee, no blocked_at in the ticket file. Offer all ready tickets; when one must be chosen, the
one that unblocks the most work behind it.
"""
# BMad type -> this store's type.
[tickets.types]
initiative = "milestone" # an initiative is a milestone, not an issue; its epics carry it
epic = "epic"
story = "story"
spike = "spike"
bug = "bug"
# BMad status -> this store's status. draft is local only and never sent to the store.
[tickets.status]
backlog = "backlog"
in-progress = "in-progress"
review = "review"
done = "closed" # state, not a label
dropped = "closed: not planned" # state, not a label
config/jira-ticketing.toml›
# Ticketing config: Jira Cloud. Read by every skill that creates, reads, or moves tickets.
# A starter: at first use the skill copies the chosen one to _bmad/custom/ticketing-store-config.toml
# (references/store-setup.md), and that copy is the one edited. An org can instead host its own starter
# anywhere — a repo path or a url — and setup copies from it. Skills read
# [tickets] and [verbs] through a script, so comments are never loaded.
# Every ticketing config has the same fields; only the prose and the maps differ. `description` sits
# outside [tickets] so it is never loaded; it is for choosing a config.
# One tool: acli, Atlassian's official CLI (`acli jira workitem ...`): create with --parent, transition,
# links, comments, assign, JQL. The Atlassian Rovo MCP server (https://mcp.atlassian.com/v1/mcp/authv2)
# is the alternative for an org that prefers MCP; it has no link-creation tool.
#
# The active initiative is `modules.bmm.active_initiative` in _bmad/custom/config.user.toml, read at activation. The ticket tree under root
# has the same layout for every store (layout in references/board.md, Layout); write pushes it to this tracker.
# An empty field below that an operation needs, and that cannot be inferred from what the user gave:
# use what they tell you for this run and offer to record it in this file (references/store-setup.md).
description = "Jira Cloud through acli: work types, transitions, links, JQL. For teams already on Jira, usually with an admin who can adjust the project workflow, or customize this starter."
[tickets]
store = "jira"
root = "{output_folder}"
key = "" # Jira project key, e.g. "SHOP"; Jira assigns the number
site = "" # https://yourorg.atlassian.net
access = """
Use acli against `site`. Custom fields go through `--from-json`; check a subcommand's spelling
with `acli jira workitem --help` before first use. If a call fails on auth, a work type, or a
status, tell the user what is blocking, confirm the setup with them, and ask them to authenticate when that is the cause.
"""
fields = """
severity is Jira's priority: P0 = Highest, P1 = High, P2 = Medium, P3 = Low; set it at create
(--priority, or --from-json when the flag is absent) and change it the same way. risk is a label
risk-low, risk-medium, or risk-high. estimate is the Story Points field (points on a story; the t-shirt as a label est:<size> on an epic), through --from-json. Empty field: default priority, no label.
"""
reference = """
A reference is a line in the ticket's References section: `type — location, section`, location a
path from {project-root} or a url for a remote source, opened before it is cited. acli creates no web links; the description line is the
reference.
"""
# Verb prose lives in its own table so a skill can load one verb at a time.
[verbs]
setup = """
Connect: `brew tap atlassian/homebrew-acli && brew install acli`, then
`acli jira auth login --web --site <site>`.
acli cannot create work types or statuses; those live in the project's settings. At first publish,
read an existing work item (`acli jira workitem view <key> --json --fields issuetype,status`) and
the project's work types, compare with [tickets.types] and [tickets.status], and tell the user
exactly what to add or what to change in their copy of this file. Jira's defaults (To Do, In
Progress, Done; resolution Won't Do) need no setup; the maps below also expect an In Review status.
Do not ask for a level above Epic: by default the initiative stays local and everything below it
publishes. A team-managed project's admin adds a status as a board column under
Project settings, Board; a company-managed project needs a Jira admin to add it to the workflow.
"""
write = """
Create, parents before children, blockers first:
`acli jira workitem create --project <key> --type <per [tickets.types]> --summary <t>
--description-file <b> --parent <key> --label <type>[,hitl,risk-<risk>]`, priority per `fields` on
a bug. Changes: `edit` (body, labels), `assign`, `transition --status <per [tickets.status]>` —
status never moves by edit — `link create --out <blocker> --in <blocked> --type "Blocks"`,
`comment create`. No parent: omit --parent — the item sits loose in the project. Custom fields go through --from-json; when dropped shares a status with done,
set the resolution the same way so the two stay distinct.
Initiative: with [tickets.types].initiative empty, the default, it is never sent — everything below
it publishes, the initiative stays local, and epics sit loose in the project. Set that type only when the
project has a level above Epic; then epics take it as --parent.
The body sent is the local file without the frontmatter lines id, remote, status, assignee; build
notes belong in <ticket filename>-plan.md beside it, added as a comment on the move to review
(or done when there is no review) — acli has no attachment upload.
After a write, mirror id (the key), remote (the url), status, assignee, and blocked_by in the
local file; commit local files on the current branch when root is in a git repo, never push.
"""
query = """
One work item: `acli jira workitem view <key> --json --fields
key,issuetype,summary,status,assignee,parent,labels,issuelinks,resolution,description`. blocked_by is the
"is blocked by" links; Done with resolution Won't Do is dropped; the description's covers holds the source requirement ids. Children:
`search --jql "parent = <key>"`. Words: `search --jql 'project = <key> AND text ~ "<words>"'`,
narrowed by issuetype or status per the maps. The local file: `grep -rl "^id: <key>" root` or the
filename.
A child the tracker holds with no file in the tree gets one, numbered after the last.
Candidates to pull (refine thin tickets before starting): status [tickets.status].backlog, every "is blocked by" link done, unassigned, no blocked_at in the ticket file. Offer all
ready tickets; when one must be chosen, the one that unblocks the most work behind it.
"""
# BMad type -> this store's type.
[tickets.types]
initiative = "" # empty: the initiative stays local. Jira's default hierarchy is Epic > Story > Subtask; a level above Epic needs Plans (Premium or Enterprise). Set to "Initiative" only when the project has that level.
epic = "Epic"
story = "Story"
spike = "Task" # plus label spike
bug = "Bug"
# BMad status -> this store's status. draft is local only and never sent to the store.
[tickets.status]
backlog = "To Do"
in-progress = "In Progress"
review = "In Review" # not a default Jira status; `setup` asks for it. Leave empty for no review
done = "Done"
dropped = "Done" # Jira default has no separate status; resolution Won't Do distinguishes it
config/linear-ticketing.toml›
# Ticketing config: Linear. Read by every skill that creates, reads, or moves tickets.
# A starter: at first use the skill copies the chosen one to _bmad/custom/ticketing-store-config.toml
# (references/store-setup.md), and that copy is the one edited. An org can instead host its own starter
# anywhere — a repo path or a url — and setup copies from it. Skills read
# [tickets] and [verbs] through a script, so comments are never loaded.
# Every ticketing config has the same fields; only the prose and the maps differ. `description` sits
# outside [tickets] so it is never loaded; it is for choosing a config.
# One tool: Linear's official MCP server. Initiatives hold projects, parent issues are epics, labels carry
# type, relations carry blocking. Linear ships no CLI; community CLIs are not used here.
#
# The active initiative is `modules.bmm.active_initiative` in _bmad/custom/config.user.toml, read at activation. The ticket tree under root
# has the same layout for every store (layout in references/board.md, Layout); write pushes it to this tracker.
# An empty field below that an operation needs, and that cannot be inferred from what the user gave:
# use what they tell you for this run and offer to record it in this file (references/store-setup.md).
description = "Linear through its MCP server: projects, parent issues, relations, labels. For product teams on Linear."
[tickets]
store = "linear"
root = "{output_folder}"
key = "" # Linear team key, e.g. "SHOP"; Linear assigns the number
team = "" # team key or name
access = """
Use the Linear MCP (create_issue, update_issue, create_project, update_project, get_issue,
list_issues, list_issue_statuses, create_issue_label, create_comment) against `team`. Confirm from the tool schema whether parent
and blockedBy take identifiers (SHOP-12) or ids. If a call fails on auth, a label, or a state,
tell the user what is blocking, confirm the setup with them, and ask them to authenticate when that is the cause.
"""
fields = """
severity is Linear's priority, set on the issue: P0 = Urgent, P1 = High, P2 = Medium, P3 = Low.
risk is a label risk:<value>; `setup` creates the three. estimate is Linear's estimate field on a story; a label est:<size> on an epic. Empty field: no priority, no label.
"""
reference = """
A reference is a line in the ticket's References section: `type — location, section`, location a
path from {project-root} or a url for a remote source, opened before it is cited. Also pass the url in the issue's links so it shows in
the sidebar.
"""
# Verb prose lives in its own table so a skill can load one verb at a time.
[verbs]
setup = """
Connect: `claude mcp add --transport http linear-server https://mcp.linear.app/mcp`, then sign in
over OAuth. Do not use the deprecated /sse endpoint.
`create_issue_label` for every non-empty value in [tickets.types], `hitl`, and risk:low, risk:medium,
risk:high that the team lacks. States
cannot be created from the MCP: `list_issue_statuses`, compare with [tickets.status], and tell the
user what to add under Settings, Teams, Issue statuses, or what to change in their copy of this
file. Linear's defaults (Backlog, Todo, In Progress, Done, Canceled) need no setup; the maps below
also expect an In Review state in the Started group. Offer this at first publish.
"""
write = """
`create_issue` creates, `update_issue` changes — title, description, state, assignee, parent,
blockedBy, labels, priority, project; confirm the exact tool names against the server's list at
first use. Labels carry type, hitl, and
risk:<value>; priority carries severity per `fields`; the tool schema says whether parent and
blockedBy take identifiers or ids. A standalone ticket: omit parent and project. With [tickets.types].initiative empty, the default,
the initiative is never sent and everything below it publishes, epics as parent issues; set it to
map onto a Linear Initiative only when the server exposes initiative tools, otherwise it stays
local. Create parents before children, blockers first.
The body sent is the local file without the frontmatter lines id, remote, status, assignee; build
notes belong in <ticket filename>-plan.md beside it, posted with `create_comment` on the move
to review (or done when there is no review).
After a write, mirror id (the identifier), remote (the url), status, assignee, and blocked_by in
the local file; commit local files on the current branch when root is in a git repo, never push.
"""
query = """
One issue: `get_issue` with includeRelations — labels, priority, state, assignee, parent,
project, relations, description; blocked_by is the "blocked by" relations, and the description's
covers holds the source requirement ids. Many: `list_issues` by project, parent, label, state, or
query words. The local file: `grep -rl "^id: <identifier>" root` or the filename.
A child the tracker holds with no file in the tree gets one, numbered after the last.
Candidates to pull (refine thin tickets before starting): state [tickets.status].backlog, every "blocked by" relation done, unassigned, no blocked_at in the ticket file. Offer all
ready tickets; when one must be chosen, the one that unblocks the most work behind it.
"""
# BMad type -> this store's type.
[tickets.types]
initiative = "" # empty: the initiative stays local. Linear has no Epic object and Projects cannot nest; set to "initiative" only to map onto Linear Initiatives, which nest five deep and allow several parents, so the tree cannot round-trip.
epic = "epic" # label on a parent issue
story = "story"
spike = "spike"
bug = "bug"
# BMad status -> this store's status. draft is local only and never sent to the store.
[tickets.status]
backlog = "Backlog" # or "Todo" — the team's choice
in-progress = "In Progress"
review = "In Review" # not a default Linear state; `setup` asks for it. Leave empty for no review
done = "Done"
dropped = "Canceled"
config/notion-ticketing.toml›
# Ticketing config: Notion. Read by every skill that creates, reads, or moves tickets.
# A starter: at first use the skill copies the chosen one to _bmad/custom/ticketing-store-config.toml
# (references/store-setup.md), and that copy is the one edited. An org can instead host its own starter
# anywhere — a repo path or a url — and setup copies from it. Skills read
# [tickets] and [verbs] through a script, so comments are never loaded.
# Every ticketing config has the same fields; only the prose and the maps differ. `description` sits
# outside [tickets] so it is never loaded; it is for choosing a config.
# One tool: Notion's official MCP server. One database holds every ticket; properties carry type,
# status, assignee, and hitl; two self-relations carry hierarchy (Parent) and blocking (Blocked by);
# a board view grouped by Status is the kanban. `setup` creates all of it.
#
# The active initiative is `modules.bmm.active_initiative` in _bmad/custom/config.user.toml, read at activation. The ticket tree under root
# has the same layout for every store (layout in references/board.md, Layout); write pushes it to this tracker.
# An empty field below that an operation needs, and that cannot be inferred from what the user gave:
# use what they tell you for this run and offer to record it in this file (references/store-setup.md).
description = "Notion through its MCP server: one tickets database with a board view, relations for hierarchy and blocking. For teams that plan in Notion and want tickets beside their docs."
[tickets]
store = "notion"
root = "{output_folder}"
key = "" # prefix of the database's ID property, e.g. "SHOP" gives SHOP-12; empty = page ids only
database = "" # url or id of the tickets database; empty = `setup` creates it
access = """
Use the Notion MCP (notion-fetch, notion-create-pages, notion-update-page,
notion-query-data-sources, notion-create-comment, notion-get-users) against `database`. Confirm
from the tool schema how relation and person properties are passed. If a call fails on auth or
`database` is empty, tell the user what is blocking, confirm the setup with them, and ask them to authenticate when that is the cause.
"""
fields = """
risk and severity are select properties: Risk (low, medium, high) and Severity (P0-P3); `setup`
creates them, publish and update set them. estimate is a text property Estimate, created with the rest. Empty field: property left empty.
"""
reference = """
A reference is a line in the ticket's References section: `type — location, section`, location a
path from {project-root} or a url for a remote source, opened before it is cited. Notion renders urls as links; a Notion page url becomes
a mention.
"""
# Verb prose lives in its own table so a skill can load one verb at a time.
[verbs]
setup = """
Connect: `claude mcp add --transport http notion https://mcp.notion.com/mcp`, then sign in over
OAuth and share the database, or the page it will be created under, with the connection.
When `database` is empty: ask which page to create under, then `notion-create-database` named
Tickets with properties Title (title), Type (select, options per [tickets.types]), Status (status,
options per [tickets.status]), Parent (relation to this database), Blocked by (relation to this
database), Assignee (person), Covers (text), Risk (select: low, medium, high), Severity (select:
P0, P1, P2, P3), Estimate (text), hitl (checkbox), and, when `key` is set, ID (unique id
with that prefix). Then `notion-create-view`: a board grouped by Status. Give the user the url and
put it in `database` in this file.
When `database` is set: `notion-fetch` it and compare its properties and options with the maps;
add a missing option with `notion-update-data-source`, and tell the user about anything the
schema needs a person to change. Offer this at first publish.
"""
write = """
`notion-create-pages` into `database` creates; `notion-update-page` changes any property or the
content — Status, Assignee, Parent, Blocked by, Covers, Risk, Severity, hitl. The tool schema says
how relation and person properties are passed; `notion-get-users` resolves a name. No parent: leave the Parent relation empty. An initiative
is a page of Type initiative with no Parent. Create parents before
children, blockers first.
The body is the page content, sent without the frontmatter lines id, remote, status, assignee;
build notes belong in <ticket filename>-plan.md beside it, posted with
`notion-create-comment` on the move to review (or done when there is no review).
After a write, mirror id (the ID property when `key` is set, else the page id), remote (the page
url), status, assignee, and blocked_by in the local file; commit local files on the current branch
when root is in a git repo, never push.
"""
query = """
One page: `notion-fetch` — Title, Type, Status, Assignee, Parent, Blocked by, Covers, Risk,
Severity, hitl, and the content. Many: `notion-query-data-sources` on `database` filtered by any
property; `notion-search` scoped to `database` for a looser match. The local file: `grep -rl
"^id: <id>" root` or the filename.
A child the tracker holds with no file in the tree gets one, numbered after the last.
Candidates to pull (refine thin tickets before starting): Status [tickets.status].backlog, every page in Blocked by done, Assignee empty, no blocked_at in the ticket file. Offer all
ready tickets; when one must be chosen, the one that unblocks the most work behind it.
"""
# BMad type -> this store's type (Type select options; `setup` creates them).
[tickets.types]
initiative = "initiative"
epic = "epic"
story = "story"
spike = "spike"
bug = "bug"
# BMad status -> this store's status (Status property options; `setup` creates them).
# draft is local only and never sent to the store.
[tickets.status]
backlog = "Backlog"
in-progress = "In Progress"
review = "Review"
done = "Done"
dropped = "Dropped"
config/repo-ticketing.toml›
# Ticketing config: tickets as files in the repo (default). Read by every skill that creates, reads, or moves tickets.
# A starter: at first use the skill copies the chosen one to _bmad/custom/ticketing-store-config.toml
# (references/store-setup.md), and that copy is the one edited. An org can instead host its own starter
# anywhere — a repo path or a url — and setup copies from it. Skills read
# [tickets] and [verbs] through a script, so comments are never loaded.
# Every ticketing config has the same fields; only the prose and the maps differ. `description` sits
# outside [tickets] so it is never loaded; it is for choosing a config.
#
# Tickets and artifacts live together as markdown files under root (layout in references/board.md,
# Layout), committed in the monorepo or in a repo of their own. Status and assignee change by commit, and the skill never pushes,
# so others see them only after a push and pull.
#
# The active initiative is `modules.bmm.active_initiative` in _bmad/custom/config.user.toml, read at activation.
# An empty field below that an operation needs, and that cannot be inferred from what the user gave:
# use what they tell you for this run and offer to record it in this file (references/store-setup.md).
description = "Quick setup, and the default when nothing else is chosen. Tickets are markdown files under version control: in the monorepo, or in their own repo (recommended for mono and poly repos alike). No tracker or account needed. The simplest store — right for a solo developer or a very small team with no other tracking."
[tickets]
store = "repo"
root = "{output_folder}"
key = "" # ticket id prefix, e.g. "SHOP" -> SHOP-12. Empty = ask at first publish.
access = """
Read and write the files under root. If root is missing, tell the user what is blocking and confirm the setup with them.
"""
fields = """
risk and severity live in the frontmatter (risk: low|medium|high on any ticket; severity: P0-P3
on bugs); estimate and estimate_basis the same way. Nothing else carries them.
"""
reference = """
A reference is a line in the ticket's References section: `type — location, section`, location a
path from {project-root} or a url for a remote source, opened before it is cited.
"""
# Verb prose lives in its own table so a skill can load one verb at a time.
[verbs]
setup = """
Recommend a root outside the project: a new folder beside it — the session then launches one level
up — or in the workspace that already holds the repos. `git init` a new folder so tickets have
history; skip that when the user picks a folder already under git, the monorepo included. Then
create root/backlog/ and the active initiative's folder when they do not exist.
"""
write = """
No id yet: assign the next KEY-n (ids in use: `grep -rh "^id: " root`). Every change — body, status, assignee, parent (move the file, or the container folder, into the new
parent's folder), blocked_by — is an edit to the file. Build records never go in the
body; bmad-build writes them beside the ticket file (`<name>-plan.md`,
`<name>-session-<date>.jsonl`, `<name>-results.md`).
Commit only the files you wrote on the current branch. Root not in a git repo: skip the commit and say so.
"""
query = """
By id: `grep -rl "^id: <id>" root`; by title, the filename; by words, `grep -rli "<words>" root
--include=*.md`; by a frontmatter field, `grep -rl "^type: bug" root` — same for status, assignee,
parent. A container's children are the ticket files and container folders in its folder. Frontmatter holds id,
remote, type, title, parent, covers (the source requirement ids), blocked_by, blocked_at and
blocked_reason when blocked, assignee, status, refined, hitl, risk, estimate, and severity on bugs.
Candidates to pull (refine thin tickets before starting): status draft or backlog, blocked_at empty, every blocked_by done, unassigned. Several ready tickets are parallel work;
offer them all. When one must be chosen, take the one that unblocks the most work behind it.
"""
# BMad type -> this store's type.
[tickets.types]
initiative = "initiative"
epic = "epic"
story = "story"
spike = "spike"
bug = "bug"
# BMad status -> this store's status. The file is the store here, so draft is a real status.
[tickets.status]
draft = "draft"
backlog = "backlog"
in-progress = "in-progress"
review = "review"
done = "done"
dropped = "dropped"
config/trello-ticketing.toml›
# Ticketing config: Trello. Read by every skill that creates, reads, or moves tickets.
# A starter: at first use the skill copies the chosen one to _bmad/custom/ticketing-store-config.toml
# (references/store-setup.md), and that copy is the one edited. An org can instead host its own starter
# anywhere — a repo path or a url — and setup copies from it. Skills read
# [tickets] and [verbs] through a script, so comments are never loaded.
# Every ticketing config has the same fields; only the prose and the maps differ. `description` sits
# outside [tickets] so it is never loaded; it is for choosing a config.
# One tool: the official Trello MCP server. Trello is flat: lists carry status, labels carry type, card
# urls in descriptions carry hierarchy and blocking. Using the defaults below: the board needs the lists
# Backlog, In Progress, Review, Done, Initiatives, Epics and a label per type plus hitl; `setup` says how. Members, comments, attachments, and label creation
# are not in the MCP yet. Weakest fit of the shipped configs; expect to tune it.
#
# The active initiative is `modules.bmm.active_initiative` in _bmad/custom/config.user.toml, read at activation. The ticket tree under root
# has the same layout for every store (layout in references/board.md, Layout); write pushes it to this tracker.
# An empty field below that an operation needs, and that cannot be inferred from what the user gave:
# use what they tell you for this run and offer to record it in this file (references/store-setup.md).
description = "Trello through its MCP server: lists as status, labels as type, card links as hierarchy. For small teams that run a Trello board and want to keep it."
[tickets]
store = "trello"
root = "{output_folder}"
key = "" # ids are card short links (from the card url); leave empty
board = "" # the board url
access = """
Use the Trello MCP (trelloReadBoard, trelloReadCard, trelloWriteCard, trelloWriteChecklist,
trelloSearch) against `board`. Write tools take the server's ids, not urls: resolve a url with
`trelloReadCard get` first. If a call fails on auth, a list, or a label, tell the user what is blocking, confirm the setup with them, and ask them to authenticate when that is the cause.
"""
fields = """
risk is a label risk:<value>; severity a label P0-P3. The MCP cannot create labels: `setup` asks
the user to add them with the type labels. estimate is an `estimate:` line in the description. Empty field: no label.
"""
reference = """
A reference is a line in the ticket's References section: `type — location, section`, location a
path from {project-root} or a url for a remote source, opened before it is cited. Attachments are not in the MCP yet; the description line
is the reference.
"""
# Verb prose lives in its own table so a skill can load one verb at a time.
[verbs]
setup = """
Connect: `claude mcp add --transport http trello https://mcp.trello.com/v1`, then sign in over
OAuth with write permission.
The MCP can create a board but not lists or labels. When `board` is empty, offer to create one;
then ask the user to add the lists in [tickets.status] (Backlog, In Progress, Review, Done) plus
Initiatives and Epics, and labels for every value in [tickets.types] plus hitl, risk:low, risk:medium, risk:high, and
P0-P3. Check with
`trelloReadBoard` before first publish and name what is still missing.
"""
write = """
`trelloWriteCard create` makes a card, `update` changes description or labels, `move` changes
list, `archive` is dropped. Write tools take server ids: resolve a url with `trelloReadCard get`
first. Status is the list per [tickets.status]; initiative cards live in the Initiatives list,
epics in Epics, both carrying a `status:` line at the top of the description. Labels carry type,
hitl, risk, severity. Trello has no fields for the rest — conventions in the description:
`parent: <card url>` as its first line, an `assignee:` line (no member tools yet), blocked_by as
card urls; the parent card keeps a checklist with each child's url. A standalone ticket: no parent line. Create parents before
children, blockers first.
The body is the description, sent without the frontmatter lines id, remote, status (assignee stays, as the `assignee:` line above); build notes
belong in <ticket filename>-plan.md beside it — on the move to review (or done when there is
no review) put its path in the description as `plan:` (no comments in the MCP yet).
After a write, mirror id (the card short link), remote (the card url), status, assignee, and
blocked_by in the local file; commit local files on the current branch when root is in a git repo,
never push.
"""
query = """
One card: `trelloReadCard get` by url — name, list, labels, description (assignee, parent,
covers, blocked_by live there), checklists; a container's children are its checklist items. Many:
`trelloSearch` scoped to `board`, or `trelloReadBoard` filtered by the maps. The local file:
`grep -rl "^id: <short link>" root` or the filename.
A child the tracker holds with no file in the tree gets one, numbered after the last.
Candidates to pull (refine thin tickets before starting): in the [tickets.status].backlog list, every blocked_by card in the Done list, assignee line
empty, no blocked_at in the ticket file. Offer all ready tickets; when one must be chosen, the one that unblocks the most work
behind it.
"""
# BMad type -> this store's type.
[tickets.types]
initiative = "initiative"
epic = "epic"
story = "story"
spike = "spike"
bug = "bug"
# BMad status -> this store's status. draft is local only and never sent to the store.
[tickets.status]
backlog = "Backlog"
in-progress = "In Progress"
review = "Review"
done = "Done"
dropped = "archived"
customize.toml›
# DO NOT EDIT -- overwritten on every update.
#
# Workflow customization surface for bmad-preview-ticketing.
# Team overrides: {project-root}/_bmad/custom/bmad-preview-ticketing.toml
# Personal overrides: {project-root}/_bmad/custom/bmad-preview-ticketing.user.toml
[workflow]
# --- Universal defaults. Merge: scalars override, arrays append. ---
activation_steps_prepend = []
activation_steps_append = []
persistent_facts = [] # `file:` entries are paths or globs under {project-root}; anything else is a fact verbatim
on_complete = ""
# Write files for the whole agreed breakdown at inception, or only for the tracer bullet and what is unblocked now; the rest waits in the epic's Breakdown until pulled. The user can override.
creation = "on_pull" # on_pull | at_inception
# Publish the agreed thin breakdown now, or publish each ticket when pulled. The user can override.
publication = "on_pull" # on_pull | at_inception
# How to propose epic boundaries, offered while slicing an initiative. A team replaces this with its
# own rule, or points at where its boundaries are recorded — a url, AGENTS.md, a team map, anything
# the agent can read.
slice_to_epics = """
An epic is one capability from the source, or a tightly coupled pair, delivered to production by one owner: a dev or pair with agent lanes. With no epics, the initiative is the epic.
- Ask first whether the user wants epics at all, or one self-contained initiative.
- Propose epics along the source's capabilities. Merge two when one owner and one module deliver both; split one by module only when it genuinely spans two teams. Two epics need at most a contract between them; say what each needs from the ones before it.
- Never propose epics along architecture modules alone; a module epic has a Done when a product owner cannot read.
- No boundary applies: one epic. Split further only when the user wants a large part usable before the whole; ask.
- Split for a distinct outcome, ownership boundary, or useful feedback point, not a fixed ticket count. An epic whose boundary names more than one outcome or owner is two epics.
- The platform baseline (scaffold, environments, CI, deployment, operations) is the opening epic, or the first stories of the first epic. Every epic delivers to production; its Done when includes the integrated verification for what it delivers.
"""
# What a container (initiative or epic) must say at its altitude, and who decides it. Offered while
# authoring the initiative and completing the selected epic at inception. A team replaces the
# field guidance, the counts, or the split between the product owner and inception.
container_definition = """
The product owner and developer agree on the container's scope from the source. Every container envelope records title, intent, Outcome (for whom, what changes, the signal), Done when (three to six checks at the altitude of the source's ids), boundaries, parent, upstream covers, references, and known decisions or blockers. The initiative's Breakdown holds the epic order.
Complete the initiative before splitting it, and the selected epic before inception: the requirement source (Requirements with stable ids; a referenced numbered source; a separate spec only when the source outgrows the section); constraints as references; known unknowns in Notes; Outcome and Done when re-read against the source. Discuss unsettled fields rather than repeating decisions already made.
Inception defines the whole epic's ticket set, contributions, verification approaches, dependencies, integration points, and investigations. Decisions that bind several tickets are made or explicitly left dependent on a spike. Full story criteria and implementation plans are written when the story is pulled.
"""
# How to slice an epic into stories, at inception.
slice_to_tickets = """
A slice is one implementation step toward the epic's Done when, small enough that one agent session, starting from the refined ticket and its source, plans and finishes it. It need not be user-visible on its own; the epic is the unit of value. Plan the whole epic at inception: each thin ticket names its contribution, verification approach, dependencies, and known uncertainty; refinement later adds full criteria, not the missing remainder of the epic.
- The first ticket is the tracer bullet: the thinnest path through every layer the epic touches, proving they connect. Whatever setup that needs, including a starter the user runs, is a hitl step on that ticket. For it, and for anything the user wants demoable, the entry says what someone can see running when it is done.
- Contracts and stubs early: a boundary two lanes share gets its interface and a stub as its own slice, so both lanes open at once.
- Lanes: slices in one lane touch shared code in order; slices in different lanes never touch the same code. Two slices that would: one slice, or one blocks the other.
- Done is one runnable check. A slice whose check needs another slice's work belongs after it.
- Too small when setup outweighs the work: merge. An epic that is itself one session's work gets one slice.
- Eight to twelve slices is typical, not a limit. Fifteen can be right when they are one lane with one owner; six can be too many when two owners are inside. Past the typical range, say so and offer a split; the user decides.
- Split, never shrink: "for now", "placeholder", "simplified", "wired later" is a second slice hiding.
- After the tracer bullet, the slice the user is least sure of.
"""
# Ordering guidance offered while slicing and writing. A team tightens or replaces any of these in
# its override file.
ordering = """
- Put setup and each hitl step on the first ticket it blocks; with epics, that ticket is under the relevant epic, never loose under the initiative. Initiative-wide setup belongs to the opening epic.
- Offer an opening refactor when poor code quality or missing standards would make the epic's tickets hard, and a closing one to clean up what the epic built.
- Tests are part of every ticket, never a ticket of their own — except one closing end-to-end suite across the epic, offered when the source has a test plan or the user wants one.
"""
acceptance_criteria = """
- Each criterion is one behavior someone can observe and check without having written the code.
- Each is false before this ticket and true after it, through this ticket's work alone.
- Given/When/Then at the level of behavior, not mechanics: "Given a signed-in user with an empty cart", never "click login, then click cart". What must be true, never how to build it; a criterion that names a function, file, or library is an implementation step in disguise.
- The rule, not an example: "rejects any quantity over stock on hand", not "rejects quantity 999". A literal only when the value is the requirement — a limit, a rounding rule, exact text.
- Cover the happy path, the boundaries, and the failure cases that matter. One criterion per rule, not per test case.
- Enough that building the wrong thing cannot pass, no more: usually three to eight. More means split, or the criteria became a test plan.
"""
# The risk and severity scales. A team replaces the scale or the floors here; how each store carries
# the fields is the `fields` global in the ticketing store config.
scoring = """
- Every ticket gets a risk. low: a mistake shows at once and a revert is clean. medium: a mistake can slip past review or is costly to unwind — shared code, caching, background jobs, per-environment config. high: schema migrations, data deletion or transformation, auth, payments, anything a revert cannot undo. Empty: not scored.
- High risk names one check outside the ticket's own criteria — a person who confirms, a suite beyond the ticket's tests, a monitor; named in Notes.
- A bug gets a severity. P0: outage, data loss, or security exposure — drop everything. P1: core function broken, no workaround. P2: impaired, a workaround exists. P3: cosmetic.
"""
prose = """
- Short declarative sentences, common words, the project's own vocabulary; the reader has only the ticket.
- Say each thing once; no invented terms, no filler, no metaphor.
- Offer a rewrite with the reason when a sentence breaks these; the user decides.
"""
# Validation checks by scope, beyond the standards above (those are re-checked from their own keys).
# A team adds its own here. How they run is references/validate.md.
checks = """
Each ticket:
- Every file or link referenced exists and opens; no unfilled placeholder; every assumption is marked.
- Nothing contradicts the requirement source, its companions, the architecture, or a recorded decision.
- A thin leaf (`refined: false`) has a contribution, verification approach, and known uncertainty; full criteria are required before execution, not at inception.
The set (every entry in the epic's Breakdown, written as a file or not):
- Together the tickets account for every requirement in the epic's spec, referenced numbered source, or Requirements, except scope deferred with the user. Each covers id exists there; multiple tickets may contribute to it, with their distinct contributions stated.
- UX, architecture, constraints, and integration work are accounted for even when they lack ids. The combined results must satisfy the epic's Done when; citing an id alone is not coverage.
- No cycle; every blocker real; the tracer bullet and sequencing decisions are in the epic's Notes. Investigations precede the work that needs their answers.
The tree (the initiative and its epic envelopes):
- Every initiative requirement has an accountable epic or an agreed deferral. Shared requirements name each epic's contribution; no unexplained overlap or missing integration owner.
- Each epic's covers cites parent ids. Every id it assigns in Requirements or a local spec maps to one of them, and a child citing a local id resolves through that map to a parent id; an epic with empty covers cites a source section on every line instead. Verify the map resolves; a non-empty covers is not coverage.
- Every epic envelope has Outcome, Done when, boundaries, upstream coverage, and references; a future epic needs neither stories nor a completed spec. The initiative's Breakdown lists every epic in build order, and each `after` names what is needed and from which epic. It opens with a platform-baseline epic, or a `Decision:` line says why not. Each epic's Done when includes production delivery.
- With epics, no leaf sits directly under the initiative.
Before closing a container:
- Verify its Done when against implementation evidence and its requirements, including constraints and companions. All known children done is evidence, not proof the parent is complete; explain any dropped work and confirm remaining scope with the user.
"""
# Ticket templates, one per type. Swap a template to change the criteria format (the shipped one is
# numbered Given/When/Then; a table, a checklist) or add your own sections.
# Keep a home, under any name, for each section references/ticket.md refers to: Description,
# Acceptance Criteria, Boundaries (stories), References, and Notes; for containers also Outcome,
# Requirements, Done when, and Breakdown; Reproduction and Cause Hypothesis for bugs, Approach for spikes. `Decision:`, `Dropped:`, and `Estimate:` lines live in Notes.
# Spikes use "Verify:" lines instead of Given/When/Then, and a thin story carries one Verify: line until refined; both deliberate.
initiative_template = "{skill-root}/assets/initiative-template.md"
epic_template = "{skill-root}/assets/epic-template.md"
story_template = "{skill-root}/assets/story-template.md"
spike_template = "{skill-root}/assets/spike-template.md"
bug_template = "{skill-root}/assets/bug-template.md"
# Estimation, off by default. Points and t-shirts share one unit: a t-shirt is a range of summed
# story points. How it is used is references/estimate.md; how a store carries the field is its
# `fields` global. Calibration proposes changes to the rubric and map from closed tickets.
# Must stay last: a TOML table header ends the [workflow] key list above.
[workflow.estimation]
enabled = false
leaf_scale = [1, 2, 3, 5]
rubric = "1-2: an agent can do it and it is well understood. 3-5: heavy hitl guidance, or work a person must do."
tshirt = { XS = "1-3", S = "4-8", M = "9-20", L = "21-40", XL = "41+" }
module-manifest.toml›
module = "method"
version = "6.13.0-next"
update_source = "github:bmad-code-org/BMAD-METHOD/skills"
knowledge = "`references/help.md` in the `bmad` skill"
references/board.md›
# Board
Operations on existing tickets. Read an existing ticket before changing it; if it has been published to a tracker, query its current remote state too.
## Publish and pull
Approving a breakdown records it in the epic's Breakdown and creates thin files per `{workflow.creation}`; it does not start work or publish. Resolve `{workflow.publication}`: `on_pull` keeps them local until refinement, while `at_inception` offers to publish the agreed set after validation. An explicit user request takes precedence. A single approval can cover both the split and publication when the user asks for both.
Publish through `write`, moving new tickets to backlog. With the repo store this records the approved files locally; with a tracker it creates the corresponding remote items. A type mapped to `""` is never sent: it stays local and its children attach to the nearest published ancestor, which is how an initiative behaves on a tracker whose hierarchy has no level above the epic. Say so once when it first matters rather than at every publish. At first publish, or when maps name missing fields or statuses, offer `setup`. If any prerequisite is still local, include it in the proposed publication scope.
A thin ticket may be published for planning visibility. It keeps `refined: false`; backlog means available to pull, not already detailed enough to build. With a tracker, the `refined: false` line stays in the published body, so a placeholder is not mistaken for ready work. Refinement updates that same file and remote item, preserving identity. Do not invent full criteria merely to populate a tracker.
Before starting a candidate, read it and its source. If `refined: false` or it lacks buildable criteria, follow `slice.md` to refine it first, regardless of status. Confirm blockers, including container blockers, are satisfied and it is not already assigned to someone else. Then `write` the in-progress status with the user's approval, which publishes it if still a draft.
## Progress and closure
- Progress lives on tickets, not in a separate sprint/status file. For an epic's folder, `uv run {skill-root}/scripts/tickets.py --project-root {project-root} next <folder>` proposes candidates, grouped by state. `status <folder>` reports that folder's tickets, counts, and remaining chain. For an initiative, read its container and aggregate its epic folders. With a tracker, query before either view and pass `--synced` to `next`. Its `to_create` group is the Breakdown entries with no file yet whose blockers are done; offer the next one to pull and write its file then.
- Offer all unblocked, unassigned candidates when work can run in parallel.
- Status and assignee changes go through `write`; on the repo store, for a leaf, that is `tickets.py --project-root {project-root} mark <ticket> <status> [--assignee <who>]` followed by the commit its verb describes. `mark` writes what it is told; the checks above are yours. A container's status is an edit to its file, and containers never take review. On done with estimation on, ask for the actual (`estimate.md`).
- A ticket waiting on a person or an answer, not on a blocker: set `blocked_at` (date) and `blocked_reason`; clear both when it moves. `next` skips it.
- Closing every child does not close the parent. Run the closure check in `validate.md` against its requirements and Done when; the user confirms the parent is complete.
- Drop only after a `Dropped:` line in Notes says why. A dropped ticket still blocks its dependents: remove or repoint it in each one's `blocked_by` with the user. Cancelling a container cancels its descendants after the user confirms.
- Whatever `query` returns lands in the tree: id, remote, status, assignee, and blocked_by into frontmatter, a ticket with no file gets one per the layout. A body that differs from the file: show and ask.
## Layout
```
{output_folder}/
{active_initiative}/ # example active_initiative=initiative-checkout
initiative-checkout.md
spec-checkout/
epic-cart-rules/
epic-cart-rules.md
spec-cart-rules/
story-01-cart-service-scaffold.md
story-01-cart-service-scaffold-plan.md # written by bmad-build
story-02-cart-ui-shell.md # thin: contribution, verification, references
spike-03-discount-engine-latency.md
backlog/
bug-01-checkout-total-ignores-discount-codes.md
```
references/estimate.md›
# Estimating
Only when `{workflow.estimation}` has `enabled = true`; otherwise never raise it. Points and t-shirts share one unit: a t-shirt is a range of summed story points, so an epic sized before inception and its stories pointed later reconcile.
## Where an estimate lives
`estimate` in every ticket's frontmatter; `estimate_basis` in a container's. The basis says what the number rests on: `envelope` (intent only), `spec`, `drafts` (thin tickets pointed), `stories` (refined stories pointed). Every re-estimate appends an `Estimate:` line in Notes — old value, new value, basis, reason — so drift is visible. The store's `fields` global says how a tracker carries it.
## When to offer one
- Epic definition complete, before the story breakdown: an imagined split. Name the probable stories, point each per the rubric, sum, map to the t-shirt. The reasoning goes in the epic's Notes, marked as imagined; inception replaces it. Basis `spec`, or `envelope` when there is none.
- Whole epic breakdown approved: point every Breakdown entry, file or not, and re-estimate the epic from the sum. Basis `drafts`; refinement can change the estimate.
- Story refined: point it from its criteria and re-sum the whole ticket set if it moved. Basis `stories` once every ticket is refined; publication status does not change the basis.
- Story closed: ask whether the actual matched. A miss is a Notes line on the story; a 1-2 that needed a person is the miss that matters most.
## What the size says
XL, or a sum above the map's top range, is the signal to offer splitting the epic before it is sliced: say which imagined stories cluster into what, per `{workflow.slice_to_epics}`. S or below at the envelope is the signal for the Small epic path (SKILL.md, Intake). The user overrides either way; an override is a `Decision:` line.
## Calibration
On request, or offered once a store holds ten or more closed tickets with both an estimate and an actual: `query` them, hand a subagent the pairs and the current rubric and map, and take back a proposed rubric and map that fit the team's distribution, with the reason for each change. Show it; write it to the team override of `customize.toml` only with approval.
references/slice.md›
# Slicing
An initiative is sliced into epics: containers the product owner and developer own and complete.
Inception plans the whole selected epic so AI agents can build it: all anticipated tickets, their contributions, dependencies, verification approaches, and known uncertainty. Detailed acceptance criteria and implementation planning wait until each ticket is pulled. `{workflow.slice_to_epics}` and `{workflow.slice_to_tickets}` carry the recommended split; the user's preference comes first. `{workflow.ordering}` says what opens and closes a parent.
When an initiative has epics, every story is under an epic. An initiative with no epics is planned like an epic with stories directly underneath.
## Every container, in order
Apply this when authoring the initiative or incepting the selected epic.
1. **Envelope.** A document in the folder is an input, not the container. When the container file is missing, offer to create it from its template: title, a paragraph of intent, Outcome, Done when, boundaries, known decisions, and references. An epic also records its parent and the parent requirement ids it owns in `covers`. Done when is three to six checks at the altitude of the source's ids; it is the definition of the container and is never deferred.
2. **The requirement source at this altitude.** The container's own Requirements section holds it: the source's lines as stable ids, each mapping to a parent id in `covers`. A referenced numbered source replaces it; a numbered spec anywhere in the container's folder is that source. Offer `bmad-spec` only when the source outgrows the section or the user asks; a `spec-<slug>/` beside the container then owns the ids, and `covers` still maps them upward. The architecture spine, UX design, and research inform everything below either way.
3. **Complete the container** per `{workflow.container_definition}` and `ticket.md`: the requirement source, References, and a re-read of Outcome and Done when against it. Propose the fields together with reasons, discuss what is unsettled, and confirm before slicing. With estimation on, offer an imagined-split size per `estimate.md`; XL is the cue to offer splitting the epic first.
## Learn the codebase and team first
Start from the parent chain: the parent ticket, its spec, and what they reference, including the design when the work is user-facing. Then the codebase: greenfield or brownfield; mono or poly repo; team, service, and UI boundaries; vocabulary and recorded decisions — from the architecture document, else the repo layout. Then the areas the slices will touch, enough to draw lanes that do not collide. Tell the user what you read and concluded; ask what is wrong or missing and whether there are other references or tools you do not already know of.
## Ask the questions that decide the split
Use what is already known. Ask the remaining questions that change the split: what is first worth demoing; what is least certain; what will the first piece teach about the rest; whether the user has a split in mind; how the team defines epics. Group related questions and say which answer you would pick and why.
## Initiative into epics
Existing epics are the working set: read them first and refine in place, or drop one per `board.md` when it no longer fits; add only after the user confirms the set is insufficient. Present the proposed set in recommended build order: for each, title, one to three sentences of what is true when it is done, the parent ids it owns, its boundary, and what it needs from the epics before it. Name the tracer path across epics when the first demo cuts through several. Work with the user on order, boundaries, merges, splits, and anything unplaced.
On confirmation, write the order into the initiative's Breakdown, `after` naming what each epic needs from an earlier one; `blocked_by` on an epic only for a whole-epic gate. Each new epic gets its folder and envelope with Outcome and Done when. Run the tree check in `validate.md` against the initiative and these envelopes. Stop at this level unless the user wants to incept an epic now; complete and plan only the selected epic. The others retain their scope, references, and place in the order without story files.
## Epic into stories
The epic is ready to be worked and its spec exists or the user chose to go without. Read all existing child tickets and their results before proposing changes. Plan the entire epic per `{workflow.slice_to_tickets}` and `{workflow.ordering}`, not only its next story. Use a spike where an investigation must precede implementation; keep dependent work visible with the uncertainty stated rather than guessing its design.
Present one numbered breakdown in build order. Each entry names its type and title, `hitl` when needed, requirement ids and its contribution to them, blockers, what exists when it is done, how that result will be verified, and any unresolved question. Name the tracer bullet, what can run in parallel, and any deferred scope. Adjust size, order, and blockers with the user until they approve the set; past the size in `{workflow.slice_to_tickets}`, offer a split first. A split at inception is a second epic folder and envelope, a new line in the initiative's Breakdown with its `after`, the covers ids moved, and the agreed entries placed under the right epic; no file is renamed.
On approval, write the whole set into the epic's Breakdown in the template's line shape, then resolve `{workflow.creation}`: `on_pull` writes files only for the tracer bullet and whatever else is unblocked now; `at_inception` writes a file for every entry. A pulled entry keeps its nn. The user's request overrides. Each file comes from its type's template frontmatter, `status: draft`, `refined: false`, with parent, `covers`, `blocked_by`, `hitl`, and risk filled. Use sibling file names for local blockers so numbering and ticket ids stay distinct. Keep the Description and the `Verify:` line to a sentence each, add the nearest source in References, and put only ticket-local lines in Notes. Full criteria and implementation detail wait. With estimation on, each carries its points.
Record the breakdown's decisions in the epic's Notes as dated `Decision:` lines — tracer bullet, sequencing, deferred scope — then run the set check in `validate.md`. Fix or resolve gaps with the user before publication. Notes carry the decisions, Breakdown carries the entries, files carry the work; status lives on files only. Publication follows `board.md` and is separate from approving the split.
## Refining a story
When a ticket is pulled, read it, its source, the finished siblings and the build records beside them, and the code it will touch at the revision the work starts from. A document that contradicts the code goes to the user before criteria are written. Confirm its blockers are done and expand it per `ticket.md`: criteria, boundaries, references, and decisions. Resolve questions that prevent implementation; set `refined: true` when the user approves. A reply approves what was presented; publication and starting work are approved separately unless the user asks for them together. Refining alone does not change status or assignee.
When completed work changes the picture, revisit the whole remaining breakdown with the user. Update unstarted tickets in place, preserving identities; published changes go through the store. Do not rewrite completed or active work as a new plan. Re-run the set check and record the reason in the epic's Notes.
references/store-setup.md›
# Setting up the store
Runs at first use — the store config `{project-root}/_bmad/custom/ticketing-store-config.toml` is missing or unreadable — and whenever the user wants the store configured, reconfigured, or switched. The outcome is a working config at that path and a store proven against its own verbs.
1. **Choose.** Offer the starters in `{skill-root}/config/` by the `description` each opens with; the user may also bring their own file or url, or build a custom one from the nearest starter. Quick setup is the repo starter: files, no account, working in minutes. Every hosted starter has a free tier; choosing one costs an account setup, or only the config when the user already has a personal or enterprise account. Copy the choice to the store config path.
2. **Review.** Open the copy with the user: suggest they read it, and answer their questions from it. List every empty field an operation needs — key, site, team, database — and fill them from their answers. The copy is theirs; edits survive skill updates.
3. **Connect.** Offer to run the store's `setup` verb: tool install, auth, and the labels, lists, statuses, or properties the maps name.
4. **Prove it.** Offer a test. A store that already has items: `query` a few back — that proves auth, the maps, and the path back. An empty store: create one ticket in `backlog/` titled "BMad setup test — safe to delete" and prove `write` and `query`: create it, query it back, change its status, assign it, then drop it. Give the user the `remote` url so they can watch the item and its history; a repo store has no url — the proof is the file in the layout and the commit.
5. **Customize.** Offer a `bmad-customize` pass on this skill: review the defaults in its `customize.toml` — slicing, publication timing, ordering, criteria, scoring, checks, the templates — so the user knows what is theirs to change. `bmad-customize` decides what changes and whether they land in the team or personal override file. When it finishes, return here for the restart guidance.
6. **Restart.** Suggest clearing the session, or starting a new one, and rerunning what they came to do: the next activation reads the finished config clean.
references/ticket.md›
# Writing a ticket
A ticket starts from what is known: what exists when it is done, how it will be verified, what must not change, and what is already decided. Ask only what remains unsettled, offering a default for each, then draft into the ticket tree. Open only the template for the type: `{workflow.initiative_template}`, `{workflow.epic_template}`, `{workflow.story_template}`, `{workflow.spike_template}`, or `{workflow.bug_template}`. Its placeholders say what each section holds; its example is the level of detail to match, not copied.
## Each fact lives in one place
The input (PRD, brief, notes) owns the product argument. The requirement source at a level is the container's own Requirements section, an existing numbered source, or a separate spec when the source outgrows the section. Reference that source rather than duplicating it. The container owns Description, Outcome, Done when, Boundaries, References, Notes. An epic's `covers` records the parent requirement ids it owns, and every id it assigns locally — in Requirements or its own spec — maps to one of them. An initiative's `covers` records its source ids. Adding a local spec does not replace upstream coverage; update affected references and child mappings with it.
A story under an epic is one slice of its build order: `covers` cites ids from the epic's requirement source, and its description names its contribution. Several stories can contribute to one requirement. On refinement, add criteria for what changes and for the failure paths, boundaries, and binding decisions; point at the source for the rest.
## Rules the template cannot carry
- Acceptance criteria follow `{workflow.acceptance_criteria}`; every sentence follows `{workflow.prose}`. When the user's text misses either, offer the rewrite with the reason; show what is missing, not only what is written.
- References name the nearest document, not the documents behind it. Attach per the store's `reference` global.
- No source-code paths or snippets; the builder reads the repo. A snippet stays only when it is the decision itself, not an illustration of it. A path the user wants recorded goes in Notes.
- A UI ticket links its design in References; criteria stay functional, layout lives in the design. No design and user-facing: offer `bmad-ux` first; declined, say the builder will guess the layout unless they add details in Notes.
- `hitl: true` only when a person must do part of the work; say which step in the Description, and spell known steps out in the criteria or Notes.
- Risk on every ticket, severity on a bug, proposed per `{workflow.scoring}` with a one-line reason; the user's value wins.
- A bug carries a reproduction and a cause hypothesis, never a fix. Missing steps: ask; unclear: tighten until someone else could follow them. Run them when cheap; if the behavior already holds, say so with evidence and create nothing. Criteria include tests for the condition found and fixed, and name the other valid outcome: proof no change is needed.
- A spike names the question, who waits on the answer, and where it is recorded. A spike is `hitl`.
- Notes holds what is not in the repo or the source and what is unsettled, each line marked, and only what is local to this ticket. Anything touching more than one ticket lives in the parent's Notes and is referenced; anything that gates work becomes a spike its dependents list in `blocked_by`. `Assumption:` — offer each; confirmed, it becomes a dated decision; corrected, the ticket changes. `Open question:` — answering it is part of the ticket's work when it starts. Never resolve either by guessing.
- A container's Breakdown lists its agreed children in order; the files in its folder are the children that exist; status lives on the files.
## Refining an existing ticket
Read the local ticket and open what it references; query its remote state if already published to a tracker. With the user: confirm they still agree with it; check its contribution against the current requirement source, criteria and references; find what is missing, unclear, or wrong; settle questions that prevent implementation. Save unpublished changes locally; published changes go through `write`. Preserve identity and the existing status and assignee. A thin ticket keeps `refined: false` until the full ticket passes self-review and the user approves it. A container may end in a re-slice per `slice.md`.
## Self-review before the user sees it
Read the ticket back at its current level of detail: a thin ticket needs its contribution and verification approach; a refined one needs runnable acceptance criteria and settled prerequisites. Check size per `{workflow.slice_to_tickets}`, wording, references, and source consistency. Fix what you find; mention changes to the user's intent.
references/validate.md›
# Validating tickets
Run `{workflow.checks}` through agents that were not in this conversation. At initiative slicing, check the tree's scope ownership without requiring stories or full detail in future epics. At inception, check the epic's whole Breakdown, entries without files included, before publication. At refinement, check the individual ticket before execution. Before container closure, check implemented coverage and Done when.
- One subagent per incepted epic: its container with its Breakdown, every child ticket including completed work, requirement source and companions, and ticket/set checks. One subagent for the tree: the initiative, epic envelopes, source, and tree checks. A single ticket: one subagent with its source and ticket checks.
- Give each agent the relevant `{workflow}` keys and say which artifacts are thin versus refined. Do not flag a thin artifact for lacking criteria or Done when.
- Merge findings into fix (mechanical), suggest (a guideline, with its reason), or ask (needs the user). Resolve coverage gaps and contradictions before proceeding; the user decides suggestions and scope changes.
- A declined suggestion recorded as a `Decision:` line is not raised again unless new evidence changes its basis.
The user may request any scope independently.
scripts/read_toml.py›
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Read named keys from a TOML file without loading the rest into context."""
import argparse
import json
import sys
import tomllib
from pathlib import Path
sys.dont_write_bytecode = True
_MISSING = object()
def load(source: str) -> dict:
return tomllib.loads(Path(source).expanduser().read_text(encoding="utf-8"))
def extract(data, dotted: str):
current = data
for part in dotted.split("."):
if isinstance(current, dict) and part in current:
current = current[part]
else:
return _MISSING
return current
def main() -> int:
parser = argparse.ArgumentParser(description="Print selected keys from a TOML file.")
parser.add_argument("--file", "-f", required=True, help="Path of the TOML file")
parser.add_argument(
"--key", "-k", action="append", default=[], help="Dotted key (repeatable). Omit for the whole file as JSON."
)
args = parser.parse_args()
try:
data = load(args.file)
except Exception as error: # noqa: BLE001 — any read or parse failure is reported the same way
sys.stderr.write(f"error: cannot read {args.file}: {error}\n")
return 1
if not args.key:
print(json.dumps(data, indent=2, ensure_ascii=False))
return 0
found = {}
missing = []
for key in args.key:
value = extract(data, key)
(missing.append(key) if value is _MISSING else found.__setitem__(key, value))
for key in missing:
sys.stderr.write(f"missing: {key}\n")
if len(args.key) == 1:
if missing:
return 2
value = found[args.key[0]]
print(value.rstrip("\n") if isinstance(value, str) else json.dumps(value, indent=2, ensure_ascii=False))
else:
print(json.dumps(found, indent=2, ensure_ascii=False))
return 2 if missing else 0
if __name__ == "__main__":
raise SystemExit(main())
scripts/tests/test_read_toml.py›
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "read_toml.py"
TOML = '''
# comment that must never be printed
[tickets]
store = "obeya"
key = ""
publish = """
Line one.
Line two.
"""
[tickets.status]
done = "done"
'''
def run(*args):
return subprocess.run([sys.executable, str(SCRIPT), *args], text=True, capture_output=True, check=False)
class ReadTomlTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.path = Path(self.tmp.name) / "t.toml"
self.path.write_text(TOML, encoding="utf-8")
def tearDown(self):
self.tmp.cleanup()
def test_single_string_key_prints_bare_value(self):
result = run("--file", str(self.path), "--key", "tickets.publish")
self.assertEqual(result.returncode, 0)
self.assertEqual(result.stdout, "Line one.\nLine two.\n")
self.assertNotIn("comment", result.stdout)
def test_multiple_keys_print_json(self):
result = run("--file", str(self.path), "-k", "tickets.store", "-k", "tickets.status")
self.assertEqual(result.returncode, 0)
self.assertEqual(json.loads(result.stdout), {"tickets.store": "obeya", "tickets.status": {"done": "done"}})
def test_missing_key_reports_and_exits_2(self):
result = run("--file", str(self.path), "-k", "tickets.nope")
self.assertEqual(result.returncode, 2)
self.assertIn("missing: tickets.nope", result.stderr)
def test_unreadable_file_exits_1(self):
result = run("--file", str(self.path.with_name("none.toml")), "-k", "tickets.store")
self.assertEqual(result.returncode, 1)
if __name__ == "__main__":
unittest.main()
scripts/tests/test_tickets.py›
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
SCRIPT = Path(__file__).resolve().parents[1] / "tickets.py"
def ticket(
status, blocked_by="[]", hitl="false", ticket_id='""', assignee='""', blocked_at=None, kind="story", refined="false"
):
lines = [
"---",
f"id: {ticket_id}",
'remote: ""',
f"type: {kind}",
'title: "x"',
"parent: EPIC-1",
"covers: [R1]",
f"blocked_by: {blocked_by}",
f"assignee: {assignee}",
f"status: {status}",
f"refined: {refined}",
f"hitl: {hitl}",
"risk: low",
]
if blocked_at:
lines.append(f'blocked_at: "{blocked_at}"')
lines += ["---", "", "# x", ""]
return "\n".join(lines)
def run(*args):
return subprocess.run([sys.executable, str(SCRIPT), *args], text=True, capture_output=True, check=False)
class TicketsTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory()
self.root = Path(self.tmp.name)
(self.root / "_bmad" / "custom").mkdir(parents=True)
self.epic = self.root / "out" / "epic-cart"
self.epic.mkdir(parents=True)
(self.epic / "epic-cart.md").write_text("---\ntype: epic\nstatus: backlog\n---\n# Cart\n")
self.write_store("repo")
def tearDown(self):
self.tmp.cleanup()
def write_store(self, name):
(self.root / "_bmad" / "custom" / "ticketing-store-config.toml").write_text(f'[tickets]\nstore = "{name}"\n')
def add(self, name, text):
(self.epic / name).write_text(text)
def seed(self, s1="draft", s2="draft", s3="draft"):
self.add("story-01-scaffold.md", ticket(s1, hitl="true"))
self.add("story-02-ui-shell.md", ticket(s2))
self.add("story-03-tracer.md", ticket(s3, blocked_by="[1, 2]"))
self.add("story-04-codes.md", ticket("draft", blocked_by="[story-03-tracer]"))
self.add("spike-05-tax.md", ticket("draft", blocked_by="[3]", kind="spike"))
def next(self, *extra):
r = run("next", str(self.epic), *extra)
self.assertEqual(r.returncode, 0, r.stderr)
return json.loads(r.stdout)
def files(self, rows):
return [r["file"] for r in rows]
def test_drafts_with_no_blockers_are_ready_to_refine(self):
self.seed()
out = self.next()
self.assertEqual(self.files(out["ready_to_refine"]), ["story-01-scaffold.md", "story-02-ui-shell.md"])
self.assertEqual(out["ready_to_start"], [])
self.assertEqual(self.files(out["blocked"]), ["story-03-tracer.md", "story-04-codes.md", "spike-05-tax.md"])
self.assertTrue(out["ready_to_refine"][0]["hitl"])
def test_unrefined_backlog_ticket_is_never_ready_to_start(self):
self.seed(s1="done", s2="backlog")
out = self.next()
self.assertEqual(out["ready_to_start"], [])
self.assertIn("story-02-ui-shell.md", self.files(out["ready_to_refine"]))
def test_backlog_is_ready_to_start_and_ready_set_moves_when_blockers_done(self):
self.seed(s1="done", s2="backlog")
self.add("story-02-ui-shell.md", ticket("backlog", refined="true"))
out = self.next()
self.assertEqual(self.files(out["ready_to_start"]), ["story-02-ui-shell.md"])
self.assertIn("story-03-tracer.md", self.files(out["blocked"]))
self.seed(s1="done", s2="done")
out = self.next()
self.assertEqual(self.files(out["ready_to_refine"]), ["story-03-tracer.md"])
def test_blockers_resolve_by_number_stem_and_id(self):
self.seed(s1="done", s2="done", s3="done")
self.add("story-06-by-id.md", ticket("draft", blocked_by="[CART-4]"))
self.add("story-04-codes.md", ticket("done", blocked_by="[story-03-tracer]", ticket_id='"CART-4"'))
out = self.next()
self.assertIn("story-06-by-id.md", self.files(out["ready_to_refine"]))
self.assertIn("spike-05-tax.md", self.files(out["ready_to_refine"]))
def test_in_progress_review_and_blocked_at(self):
self.seed(s1="in-progress", s2="review")
self.add("story-02-ui-shell.md", ticket("backlog", blocked_at="2026-09-05"))
out = self.next()
self.assertEqual(self.files(out["in_progress"]), ["story-01-scaffold.md"])
self.assertIn("story-02-ui-shell.md", self.files(out["blocked"]))
def test_status_counts_order_and_chain(self):
self.seed(s1="done")
r = run("status", str(self.epic))
self.assertEqual(r.returncode, 0, r.stderr)
out = json.loads(r.stdout)
self.assertEqual([t["n"] for t in out["tickets"]], [1, 2, 3, 4, 5])
self.assertEqual(out["counts"], {"total": 5, "done": 1, "draft": 4})
self.assertEqual(out["breakdown"], {"entries": 0, "without_file": 0})
self.assertEqual(
out["longest_remaining_chain"], ["story-02-ui-shell.md", "story-03-tracer.md", "story-04-codes.md"]
)
def breakdown_epic(self):
(self.epic / "epic-cart.md").write_text(
"---\ntype: epic\nstatus: backlog\n---\n# Cart\n\n## Done when\n\n1. x\n\n## Breakdown\n\n"
"[placeholder line that must not parse]\n"
"- 01 story — Scaffold; covers: R1\n"
"- 02 story — UI shell; blocked_by: 01; covers: R1\n"
"- 03 spike — Tax engine?; blocked_by: 01; covers: R4\n"
"- 04 story — Codes; blocked_by: 02, 03; covers: R2, R3\n\n## Notes\n\n- Decision: none\n"
)
def test_breakdown_entries_without_files_surface_when_unblocked(self):
self.breakdown_epic()
self.add("story-01-scaffold.md", ticket("done"))
out = self.next()
self.assertEqual(
[(e["n"], e["type"], e["title"]) for e in out["to_create"]],
[(2, "story", "UI shell"), (3, "spike", "Tax engine?")],
)
self.assertEqual(out["to_create"][1]["covers"], ["R4"])
self.assertEqual(out["to_create"][1]["blocked_by"], [1])
self.add("story-02-ui-shell.md", ticket("draft", blocked_by="[1]"))
out = self.next()
self.assertEqual([e["n"] for e in out["to_create"]], [3])
self.assertIn("story-02-ui-shell.md", self.files(out["ready_to_refine"]))
status = json.loads(run("status", str(self.epic)).stdout)
self.assertEqual(status["breakdown"], {"entries": 4, "without_file": 2})
def test_breakdown_entry_blocked_by_unwritten_entry_stays_hidden(self):
self.breakdown_epic()
out = self.next()
self.assertEqual([e["n"] for e in out["to_create"]], [1])
def test_breakdown_fields_in_either_order_and_malformed_line_errors(self):
self.breakdown_epic()
text = (
(self.epic / "epic-cart.md")
.read_text()
.replace(
"- 02 story — UI shell; blocked_by: 01; covers: R1", "- 02 story — UI shell; covers: R1; blocked_by: 01"
)
)
(self.epic / "epic-cart.md").write_text(text)
out = self.next()
self.assertEqual([e["n"] for e in out["to_create"]], [1])
(self.epic / "epic-cart.md").write_text(
text.replace(
"- 03 spike — Tax engine?; blocked_by: 01; covers: R4", "- 03 spike — Tax engine?; blocks: none"
)
)
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1)
self.assertIn("unknown field `blocks`", json.loads(r.stderr)["error"])
(self.epic / "epic-cart.md").write_text(
text.replace("- 03 spike — Tax engine?; blocked_by: 01; covers: R4", "- 03 Tax engine?")
)
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1)
self.assertIn("does not match", json.loads(r.stderr)["error"])
def test_dropped_blocker_still_blocks(self):
self.breakdown_epic()
self.add("story-01-scaffold.md", ticket("dropped"))
self.add("story-02-ui-shell.md", ticket("draft", blocked_by="[1]"))
out = self.next()
self.assertEqual(self.files(out["blocked"]), ["story-02-ui-shell.md"])
self.assertEqual(out["ready_to_refine"], [])
self.assertEqual(out["to_create"], [])
def test_breakdown_duplicate_number_and_unknown_blocker_error(self):
self.breakdown_epic()
text = (self.epic / "epic-cart.md").read_text()
(self.epic / "epic-cart.md").write_text(text.replace("- 03 spike", "- 02 spike"))
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1)
self.assertIn("two entries numbered 02", json.loads(r.stderr)["error"])
(self.epic / "epic-cart.md").write_text(text.replace("blocked_by: 02, 03", "blocked_by: 02, 09"))
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1)
self.assertIn("blocked_by 09, which is no entry", json.loads(r.stderr)["error"])
def test_numeric_blocker_falls_back_to_ticket_id(self):
self.add("story-01-scaffold.md", ticket("done", ticket_id="101"))
self.add("story-02-ui-shell.md", ticket("draft", blocked_by="[101]"))
self.assertEqual(self.files(self.next()["ready_to_refine"]), ["story-02-ui-shell.md"])
def test_two_files_sharing_a_number_error(self):
self.add("story-01-a.md", ticket("done"))
self.add("story-01-b.md", ticket("draft"))
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1)
self.assertIn("share the number 01", json.loads(r.stderr)["error"])
def test_malformed_input_returns_json_error(self):
(self.epic / "story-01-bad.md").write_bytes(ticket("draft").encode().replace(b"# x", b"\xff"))
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1, r.stderr)
self.assertIn("error", json.loads(r.stderr))
(self.epic / "story-01-bad.md").unlink()
self.seed()
(self.root / "_bmad" / "custom" / "ticketing-store-config.toml").write_text("[tickets\nstore = ")
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1, r.stderr)
self.assertIn("error", json.loads(r.stderr))
def test_unnumbered_leaf_sorts_last_and_container_file_is_ignored(self):
self.seed()
self.add("bug-stray.md", ticket("backlog", kind="bug"))
out = json.loads(run("status", str(self.epic)).stdout)
self.assertEqual(out["tickets"][-1]["file"], "bug-stray.md")
self.assertIsNone(out["tickets"][-1]["n"])
self.assertEqual(out["counts"]["total"], 6)
def test_mark_rewrites_status_and_assignee_on_repo_store(self):
self.seed()
r = run("mark", str(self.epic / "story-01-scaffold.md"), "in-progress", "--assignee", "ann")
self.assertEqual(r.returncode, 0, r.stderr)
self.assertEqual(
json.loads(r.stdout), {"file": "story-01-scaffold.md", "status": "in-progress", "assignee": "ann"}
)
text = (self.epic / "story-01-scaffold.md").read_text()
self.assertIn("status: in-progress\n", text)
self.assertIn('assignee: "ann"\n', text)
self.assertIn("# x", text)
def test_mark_clears_blocking_fields_and_takes_a_literal_assignee(self):
path = self.epic / "story-01-scaffold.md"
path.write_text(
ticket("backlog", blocked_at="2026-09-05").replace("---\n\n# x", 'blocked_reason: "legal"\n---\n\n# x')
)
r = run("mark", str(path), "backlog", "--assignee", "\\1")
self.assertEqual(r.returncode, 0, r.stderr)
text = path.read_text()
self.assertIn('blocked_at: ""\n', text)
self.assertIn('blocked_reason: ""\n', text)
self.assertIn('assignee: "\\1"\n', text)
self.assertEqual(self.files(self.next()["ready_to_refine"]), ["story-01-scaffold.md"])
def test_project_root_flag_finds_the_store_for_tickets_outside_the_project(self):
self.write_store("jira")
outside = tempfile.TemporaryDirectory()
self.addCleanup(outside.cleanup)
folder = Path(outside.name) / "epic-cart"
folder.mkdir()
(folder / "story-01-scaffold.md").write_text(ticket("draft"))
self.assertEqual(json.loads(run("next", str(folder)).stdout)["store"], "repo")
r = run("--project-root", str(self.root), "next", str(folder))
self.assertEqual(r.returncode, 2)
r = run("--project-root", str(self.root), "mark", str(folder / "story-01-scaffold.md"), "done")
self.assertEqual(r.returncode, 2)
def test_mark_refuses_on_tracker_store(self):
self.write_store("jira")
self.seed()
r = run("mark", str(self.epic / "story-01-scaffold.md"), "done")
self.assertEqual(r.returncode, 2)
self.assertIn("write verb", r.stderr)
def test_next_on_tracker_store_needs_synced_flag(self):
self.write_store("linear")
self.seed()
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 2)
self.assertIn("sync", r.stderr)
self.assertEqual(self.next("--synced")["store"], "linear")
def test_cycle_unknown_blocker_and_bad_status_are_errors(self):
self.seed()
self.add("story-01-scaffold.md", ticket("draft", blocked_by="[3]"))
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1)
self.assertIn("cycle", r.stderr)
self.add("story-01-scaffold.md", ticket("draft", blocked_by="[9]"))
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1)
self.assertIn("matches no sibling", r.stderr)
self.add("story-01-scaffold.md", ticket("todo"))
r = run("next", str(self.epic))
self.assertEqual(r.returncode, 1)
self.assertIn("status", r.stderr)
if __name__ == "__main__":
unittest.main()
scripts/tickets.py›
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""tickets — read a folder of ticket files and answer what is next.
A container folder holds its own ticket file, its spec, and flat leaf files named
`<type>-<nn>-<slug>.md`. Each leaf's frontmatter is the record: `status` (mirrored from the
tracker when one is configured), `blocked_by` (numbers, file names, or ids of sibling
tickets; a dropped blocker still blocks until the dependency is removed or repointed), `refined`, `hitl`, `covers`, `estimate`. The container's `## Breakdown` section lists
the agreed entries, `- nn type — title; blocked_by: nn, nn; covers: ids`; an entry with no leaf file
yet is reported under `to_create` once its blockers are done. Nothing else indexes them; this
script derives the view at read time.
next <dir> tickets whose blockers are done, grouped by state
status <dir> every ticket in number order, counts, longest remaining chain
mark <ticket-file> <status> set a leaf's frontmatter status (repo store only)
`--project-root` names the project holding `_bmad/` when the tickets live outside it.
Output is one JSON object on stdout. Exit 0 on success, 1 on a malformed ticket, 2 when
the store forbids the operation.
"""
import argparse
import json
import re
import sys
import tomllib
from pathlib import Path
sys.dont_write_bytecode = True
STATUSES = ("draft", "backlog", "in-progress", "review", "done", "dropped")
LEAF_TYPES = ("story", "spike", "bug")
CONTAINER_TYPES = ("initiative", "epic")
NAME_RE = re.compile(r"^(story|spike|bug)-(\d+)-(.+)\.md$")
BREAKDOWN_RE = re.compile(r"^-\s*(\d+)\s+(story|spike|bug)\s+[—–-]\s+(.+?)\s*$")
BREAKDOWN_ENTRY_RE = re.compile(r"^-\s*\d+\s")
class TicketError(Exception):
pass
class StoreRefusal(Exception):
pass
# ---------------------------------------------------------------- frontmatter
def parse_frontmatter(text: str) -> dict:
"""Minimal YAML subset: `key: value`, lists as `[a, b]`, quoted or bare scalars."""
m = re.match(r"\A---\n(.*?)\n---(?:\n|\Z)", text, re.S)
if not m:
return {}
data = {}
for line in m.group(1).splitlines():
if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
continue
key, _, value = line.partition(":")
value = value.split(" #")[0].strip()
data[key.strip()] = _scalar(value)
return data
def _scalar(value: str):
if value.startswith("[") and value.endswith("]"):
inner = value[1:-1].strip()
return [] if not inner else [_scalar(v.strip()) for v in inner.split(",")]
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
return value[1:-1]
if value in ("true", "false"):
return value == "true"
if re.fullmatch(r"-?\d+", value):
return int(value)
return value
def set_frontmatter_value(text: str, key: str, value: str) -> str:
m = re.match(r"\A---\n(.*?)\n---(?:\n|\Z)", text, re.S)
if not m:
raise TicketError("ticket has no frontmatter")
block = m.group(1)
pattern = re.compile(rf"^{re.escape(key)}:.*$", re.M)
if not pattern.search(block):
raise TicketError(f"frontmatter has no `{key}`")
return text[: m.start(1)] + pattern.sub(lambda _: f"{key}: {value}", block, count=1) + text[m.end(1) :]
# ---------------------------------------------------------------- tickets
def load_tickets(folder: Path) -> list[dict]:
tickets = []
for path in sorted(folder.glob("*.md")):
name_match = NAME_RE.match(path.name)
fm = parse_frontmatter(path.read_text(encoding="utf-8"))
if fm.get("type") not in LEAF_TYPES:
continue
status = fm.get("status", "")
if status not in STATUSES:
raise TicketError(f"{path.name}: status {status!r} is not one of {', '.join(STATUSES)}")
raw_blockers = fm.get("blocked_by", [])
if not isinstance(raw_blockers, list):
raise TicketError(f"{path.name}: blocked_by must be a list")
tickets.append(
{
"file": path.name,
"n": int(name_match.group(2)) if name_match else None,
"type": fm.get("type"),
"id": str(fm.get("id", "") or ""),
"title": str(fm.get("title", "") or ""),
"status": status,
"assignee": str(fm.get("assignee", "") or ""),
"refined": str(fm.get("refined", False)).lower() == "true",
"hitl": str(fm.get("hitl", False)).lower() == "true",
"covers": [str(c) for c in fm.get("covers", [])] if isinstance(fm.get("covers"), list) else [],
"estimate": fm.get("estimate", ""),
"blocked_at": fm.get("blocked_at", ""),
"raw_blocked_by": raw_blockers,
}
)
seen = {}
for t in tickets:
if t["n"] is not None and t["n"] in seen:
raise TicketError(f"{seen[t['n']]} and {t['file']} share the number {t['n']:02d}")
seen[t["n"]] = t["file"]
_resolve_blockers(tickets)
_check_cycles(tickets)
return sorted(tickets, key=lambda t: (t["n"] is None, t["n"] or 0, t["file"]))
def _resolve_blockers(tickets: list[dict]) -> None:
by_n = {t["n"]: t["file"] for t in tickets if t["n"] is not None}
by_file = {t["file"]: t["file"] for t in tickets}
by_stem = {t["file"][:-3]: t["file"] for t in tickets}
by_id = {t["id"]: t["file"] for t in tickets if t["id"]}
for t in tickets:
resolved = []
for ref in t["raw_blocked_by"]:
key = ref if isinstance(ref, int) else str(ref)
if isinstance(key, int):
target = by_n.get(key) or by_id.get(str(key))
else:
target = by_file.get(key) or by_stem.get(key) or by_id.get(key)
if target is None and isinstance(key, str) and re.fullmatch(r"\d+", key):
target = by_n.get(int(key))
if target is None:
raise TicketError(f"{t['file']}: blocked_by {ref!r} matches no sibling ticket")
resolved.append(target)
t["blocked_by"] = resolved
del t["raw_blocked_by"]
def load_breakdown(folder: Path) -> list[dict]:
"""Entries of the container file's `## Breakdown` section, in number order."""
for path in sorted(folder.glob("*.md")):
text = path.read_text(encoding="utf-8")
if parse_frontmatter(text).get("type") not in CONTAINER_TYPES:
continue
section = re.search(r"^## Breakdown\s*\n(.*?)(?=^## |\Z)", text, re.M | re.S)
if not section:
return []
entries = []
for line in section.group(1).splitlines():
line = line.strip()
m = BREAKDOWN_RE.match(line)
if not m:
if BREAKDOWN_ENTRY_RE.match(line):
raise TicketError(
f"{path.name}: Breakdown line does not match `- nn type — title; blocked_by: nn; covers: ids`: {line}"
)
continue
title, *fields = [part.strip() for part in m.group(3).split(";")]
entry = {"n": int(m.group(1)), "type": m.group(2), "title": title, "blocked_by": [], "covers": []}
for field in fields:
key, _, value = field.partition(":")
key = key.strip()
if key == "blocked_by":
entry["blocked_by"] = [int(b) for b in re.findall(r"\d+", value)]
elif key == "covers":
entry["covers"] = [c.strip() for c in value.split(",") if c.strip()]
else:
raise TicketError(f"{path.name}: Breakdown line has unknown field `{key}`: {line}")
entries.append(entry)
numbers = [e["n"] for e in entries]
for e in entries:
if numbers.count(e["n"]) > 1:
raise TicketError(f"{path.name}: Breakdown has two entries numbered {e['n']:02d}")
for b in e["blocked_by"]:
if b not in numbers:
raise TicketError(
f"{path.name}: Breakdown entry {e['n']:02d} is blocked_by {b:02d}, which is no entry"
)
return sorted(entries, key=lambda e: e["n"])
return []
def _check_cycles(tickets: list[dict]) -> None:
graph = {t["file"]: t["blocked_by"] for t in tickets}
state = {}
def visit(node, path):
if state.get(node) == "done":
return
if state.get(node) == "active":
raise TicketError("cycle through " + " -> ".join(path + [node]))
state[node] = "active"
for b in graph[node]:
visit(b, path + [node])
state[node] = "done"
for node in graph:
visit(node, [])
# ---------------------------------------------------------------- views
def classify(tickets: list[dict]) -> dict:
done = {t["file"] for t in tickets if t["status"] == "done"}
groups = {"ready_to_refine": [], "ready_to_start": [], "in_progress": [], "blocked": []}
for t in tickets:
s = t["status"]
if s in ("done", "dropped"):
continue
unblocked = all(b in done for b in t["blocked_by"]) and not t["blocked_at"]
if s in ("in-progress", "review"):
groups["in_progress"].append(t)
elif not unblocked:
groups["blocked"].append(t)
elif not t["refined"]:
groups["ready_to_refine"].append(t)
else:
groups["ready_to_start"].append(t)
return groups
def to_create(tickets: list[dict], breakdown: list[dict]) -> list[dict]:
"""Breakdown entries with no leaf file whose blockers are all done."""
by_n = {t["n"]: t for t in tickets if t["n"] is not None}
return [
e
for e in breakdown
if e["n"] not in by_n and all(b in by_n and by_n[b]["status"] == "done" for b in e["blocked_by"])
]
def longest_remaining_chain(tickets: list[dict]) -> list[str]:
remaining = {t["file"]: t for t in tickets if t["status"] not in ("done", "dropped")}
memo = {}
def chain(f):
if f in memo:
return memo[f]
best = []
for b in remaining[f]["blocked_by"]:
if b in remaining:
c = chain(b)
if len(c) > len(best):
best = c
memo[f] = best + [f]
return memo[f]
longest = []
for f in remaining:
c = chain(f)
if len(c) > len(longest):
longest = c
return longest
def public(t: dict) -> dict:
return {
k: t[k]
for k in ("n", "file", "type", "id", "title", "status", "assignee", "hitl", "covers", "estimate", "blocked_by")
}
# ---------------------------------------------------------------- store
def find_project_root(start: Path) -> Path | None:
for p in [start, *start.parents]:
if (p / "_bmad").is_dir():
return p
return None
def project_root_for(args, start: Path) -> Path | None:
return Path(args.project_root).resolve() if args.project_root else find_project_root(start)
def store_name(project_root: Path | None) -> str:
if not project_root:
return "repo"
cfg = project_root / "_bmad" / "custom" / "ticketing-store-config.toml"
if not cfg.is_file():
return "repo"
return tomllib.loads(cfg.read_text(encoding="utf-8")).get("tickets", {}).get("store", "repo")
# ---------------------------------------------------------------- commands
def cmd_next(args) -> dict:
folder = Path(args.dir).resolve()
if not folder.is_dir():
raise TicketError(f"not a folder: {folder}")
store = store_name(project_root_for(args, folder))
if store != "repo" and not args.synced:
raise StoreRefusal(f"store is {store}: sync ticket status from the tracker first, then rerun with --synced")
tickets = load_tickets(folder)
groups = classify(tickets)
return {
"folder": folder.name,
"store": store,
**{k: [public(t) for t in v] for k, v in groups.items()},
"to_create": to_create(tickets, load_breakdown(folder)),
}
def cmd_status(args) -> dict:
folder = Path(args.dir).resolve()
if not folder.is_dir():
raise TicketError(f"not a folder: {folder}")
tickets = load_tickets(folder)
breakdown = load_breakdown(folder)
counts = {}
for t in tickets:
counts[t["status"]] = counts.get(t["status"], 0) + 1
written = {t["n"] for t in tickets if t["n"] is not None}
return {
"folder": folder.name,
"store": store_name(project_root_for(args, folder)),
"tickets": [public(t) for t in tickets],
"counts": {"total": len(tickets), **counts},
"breakdown": {"entries": len(breakdown), "without_file": sum(1 for e in breakdown if e["n"] not in written)},
"longest_remaining_chain": longest_remaining_chain(tickets),
}
def cmd_mark(args) -> dict:
path = Path(args.ticket_file).resolve()
store = store_name(project_root_for(args, path.parent))
if store != "repo":
raise StoreRefusal(f"store is {store}: change status through the store's write verb, not this script")
text = path.read_text(encoding="utf-8")
if parse_frontmatter(text).get("type") not in LEAF_TYPES:
raise TicketError(
f"{path.name} is not a story, spike, or bug; containers close through the closure check, not mark"
)
text = set_frontmatter_value(text, "status", args.status)
for key in ("blocked_at", "blocked_reason"):
if parse_frontmatter(text).get(key):
text = set_frontmatter_value(text, key, '""')
if args.assignee is not None:
text = set_frontmatter_value(text, "assignee", f'"{args.assignee}"')
path.write_text(text, encoding="utf-8")
fm = parse_frontmatter(text)
return {"file": path.name, "status": fm.get("status"), "assignee": fm.get("assignee", "")}
def main() -> int:
parser = argparse.ArgumentParser(description="Read a folder of tickets and answer what is next.")
parser.add_argument("--project-root", help="project holding _bmad/; default: walk up from the ticket folder")
sub = parser.add_subparsers(dest="command", required=True)
p = sub.add_parser("next", help="tickets whose blockers are done, by state")
p.add_argument("dir")
p.add_argument("--synced", action="store_true", help="tracker status was mirrored just now")
p.set_defaults(func=cmd_next)
p = sub.add_parser("status", help="every ticket resolved")
p.add_argument("dir")
p.set_defaults(func=cmd_status)
p = sub.add_parser("mark", help="set a ticket's status (repo store only)")
p.add_argument("ticket_file")
p.add_argument("status", choices=STATUSES)
p.add_argument("--assignee")
p.set_defaults(func=cmd_mark)
args = parser.parse_args()
try:
print(json.dumps(args.func(args), ensure_ascii=False))
return 0
except StoreRefusal as e:
print(json.dumps({"error": str(e)}), file=sys.stderr)
return 2
except (TicketError, OSError, ValueError) as e:
print(json.dumps({"error": str(e)}), file=sys.stderr)
return 1
if __name__ == "__main__":
raise SystemExit(main())
SKILL.md›
---
name: bmad-preview-ticketing
description: Create and manage tickets at every level — slice an initiative into epics, break an epic into stories, write or refine a ticket, and run the board (publish, ready, move, assign, status, cancel). Use when the user says "Create a new initiative", "slice this", "incept this epic", "make a ticket", "refine this ticket", "what's ready", "status of a story", "publish ticket changes".
---
# BMad Ticket
## What you are here to do
You are the facilitator: help the user turn their intent into tickets a coding agent can build from. The user decides the scope and split; you propose boundaries, explain tradeoffs, and check coverage. Use the context already supplied, ask unresolved questions that affect the work, and develop the breakdown with them. When they delegate the thinking, investigate and self-review before presenting the result; keep assumptions and open questions visible.
At every altitude above the leaf the ideal shape is: intent (an idea, brief, PRD, intent.md) gets a container ticket, and that container is the spec at its altitude — its Requirements hold the source's lines as stable ids, informed by what else exists (an architecture spine, UX design, research), and its children are cut from them. So at any container: create its envelope if it is missing, then complete it from the source.
## Terms
- Container: an initiative or an epic — holds other tickets
- Leaf: a story, spike, or bug handed to an agent to implement. Under an epic a story is an implementation slice sequenced to reach the epic's Done when, not a user-value slice; an enabler, or work a person must do (hitl), is a story
- Thin ticket (`refined: false`): a leaf's planned contribution, requirement references, blockers, verification approach, and known uncertainty; detailed acceptance criteria wait until it is pulled
- Breakdown: a container's agreed children in build order, listed in the container; written as files or not
- Pull: take a thin ticket, refine it, then start it
- Inception: plan the whole selected epic with the user and record it in the epic's Breakdown; `{workflow.creation}` says which entries become files now
- hitl: boolean frontmatter field on a leaf; at least part needs a person
- store: the ticketing system of record — git-backed, a tracker, or both
## On activation
1. Resolve config: `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key core.output_folder --key modules.bmm.active_initiative`. Tickets are drafted under `{active_initiative}/` in `tickets.root` (step 2) — an initiative folder, or a backlog folder scoped however the user wants. Unset: offer to create the initiative folder, or a backlog folder, and record it as `active_initiative` under `[modules.bmm]` in `_bmad/custom/config.user.toml`.
2. Read the store config: `uv run {skill-root}/scripts/read_toml.py --file {project-root}/_bmad/custom/ticketing-store-config.toml -k tickets` — store guidance, access, and the type and status maps. Substitute `{output_folder}` in every value. Missing or unreadable: follow `{skill-root}/references/store-setup.md` instead of continuing.
3. Resolve `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} -k workflow.activation_steps_prepend -k workflow.activation_steps_append -k workflow.persistent_facts -k workflow.on_complete`.
4. Run `{workflow.activation_steps_prepend}`; treat `{workflow.persistent_facts}` (set with `bmad-customize`) as foundational context for the session — entries prefixed `file:` are paths or globs under `{project-root}` to load, the rest are facts verbatim — together with whatever is already in your context — registered MCP servers and CLIs, and anything injected from AGENTS.md, CLAUDE.md, or the like. Use what is known; do not ask for it again.
5. Run `{workflow.activation_steps_append}`. When the requested operation ends, run `{workflow.on_complete}`.
## Intake
Before routing, size the ask from what the user said and what is in context, and say which path you are taking and why; the user overrides, and an override is a `Decision:` line. Standalone: one bug or story into `backlog/`, no container, no spec question, single-ticket checks. Small epic: an epic envelope under the initiative, the spec question asked once and easy to decline, two to six entries, a light cohesion pass, no learn-the-codebase subagents. Full inception: the epic path in `slice.md`. Initiative: authored and split into epics per `slice.md`.
## Routing
| The user wants | Read |
|---|---|
| an initiative started or authored, split into epics; an epic incepted into stories, re-sliced | `{skill-root}/references/slice.md` |
| one ticket written or refined — a bug or a standalone story straight into `backlog/` is the fast path and needs no container and no spec question | `{skill-root}/references/ticket.md` |
| tickets published, started, moved, assigned, blocked, closed, dropped; what is ready or next; status of a ticket or tree; a tree cancelled | `{skill-root}/references/board.md` |
| a ticket, a set, or a tree validated | `{skill-root}/references/validate.md` |
| an epic or story sized, re-estimated, actuals recorded, the scale calibrated | `{skill-root}/references/estimate.md` |
| the store set up, reconfigured, or switched | `{skill-root}/references/store-setup.md` |
Save agreed work into the ticket tree; the epic's Breakdown holds entries not yet written as files. Future epics stay as envelopes until selected for inception.
### Autonomous mode
When the user asks you to do the thinking without the conversation, the same guidance, self-review, and subagents apply. Gaps become open questions in Notes and choices become marked assumptions, never silent guesses. Before publish, ask once which validations to run unless already said, and still get a yes to publish unless they said to publish too.
## Loaded on demand
Load each of the following when a step names it; resolve keys by script rather than opening `customize.toml`. Templates are opened directly.
**Customization** — `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} -k workflow.<key>` (repeat `-k`):
| Key | Holds |
|---|---|
| `slice_to_epics` | how to propose epic boundaries |
| `container_definition` | what a container says at its altitude, and what waits for inception |
| `slice_to_tickets` | how to slice an epic into session-sized implementation steps |
| `ordering` | which tickets open and close a parent |
| `acceptance_criteria` | how acceptance criteria are written |
| `scoring` | the risk and severity scales |
| `estimation` | on/off, the point scale, rubric, and t-shirt map |
| `prose` | how ticket prose reads |
| `checks` | the validation checks, by scope |
| `creation`, `publication` | whether inception writes files for the whole breakdown or only work pulled, and when tickets publish |
| `initiative_template`, `epic_template`, `story_template`, `spike_template`, `bug_template` | the template file per type |
**Store operations** — `uv run {skill-root}/scripts/read_toml.py --file {project-root}/_bmad/custom/ticketing-store-config.toml -k verbs.<name>` (repeat `-k`), then follow the verb as written:
| Verb | For |
|---|---|
| `setup` | connect the tool; create what the maps name |
| `write` | create or change a ticket — body, status, assignee, parent, blocking, fields |
| `query` | one ticket, a container's children, a search, what is ready |
How a ticket cites a document is the `reference` global, read at activation. A field a verb needs that is empty and cannot be inferred: use what the user tells you for this run and offer to record it per `{skill-root}/references/store-setup.md`.
## The ticket tree
Tickets live as markdown files under `tickets.root`; every ticket is drafted, refined, planned against, and implemented from its file there. By default the tree is the store (git-backed, the repo starter). A tracker, when configured, is a remote: `write` pushes a ticket to it, `query` reads it back, and a ticket the tracker knows but the tree does not gets its file at first `query`.
A container is a folder `<type>-<slug>/` holding its same-named ticket file, its spec, and its children. A leaf is a file `<type>-<nn>-<slug>.md` in its parent's folder, or in `backlog/` with no parent; `nn` is its order among its siblings, assigned once and never renamed. When an initiative has epics, every leaf is under one. A new ticket starts from its type's template, `status: draft`, no id; the id lives only in frontmatter (`id`, plus `remote` for a tracker). Build records and other skills' artifacts sit beside the ticket, named after it.
Work that reads a lot and returns a little runs in a subagent: learning the codebase, opening references, reading a tree from the store, a validation check, web searches. If the harness blocks subagents, say so and continue inline.