返回 Skills 目錄
bmad-code-org/bmad-method已通過檢查

SKILL DETAIL

bmad-ticket

bmad-code-org/bmad-method/bmad-ticket

Create and manage tickets — initiatives, epics, stories, bugs. Use when the user says "create a new initiative", "slice this initiative", "split this epic up", "break this into stories", "incept this epic", "make a ticket", "refine this ticket", "what's ready", "status of a story", "publish ticket changes", "validate these tickets", "set up the ticket store".

安裝量 · 328查看來源

Installation

npx skills add https://github.com/bmad-code-org/bmad-method --skill bmad-ticket

技能檔案

SKILL.md

最近同步 · 2026年10月12日

assets/bug-template.md›
---
id: [the entry's id in tickets.toml; the folder's `next_id` for a ticket with no entry]   # tracker_id and remote are written at publish on 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: []
after: []   # prerequisites only: a sibling's id; "<epic id>.<entry id>" in another epic; epic-<slug> for that whole epic
assignee: ""   # a tracker's assignee, written by `tickets.py mirror`; otherwise assignee, blocked_at, and blocked_reason live in the plan
# status lives in the plan beside this file, not here; tracker_status by a tracker sync
refined: false   # true once the user approves its criteria
hitl: false
risk: [low|medium|high]
severity: [P0|P1|P2|P3]   # P0: outage, data loss, or security exposure. P1: core function broken, no workaround. P2: impaired, a workaround exists. P3: cosmetic
---

<!-- The refined shape. A done or dropped ticket stays as it was written. -->

# [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: 7
type: bug
title: "Checkout total ignores an applied discount code after the shopper changes quantity"
parent: none
covers: []
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›
---
tracker_id: ""   # with remote (the url), written at publish on a tracker; cut on the repo store
key: ""   # tracker project or team for everything under this, when it differs from the store's
type: epic
title: "[The outcome this container exists to reach]"
parent: [folder name of the initiative]
covers: [parent capability ids this epic owns; keep these when adding an epic-local spec; a constraint is cited in References, not covered]
after: []   # epics this whole container waits on; the order of epics is in the initiative's tickets.toml
assignee: ""   # status is added when work starts (in-progress | done | dropped)
risk: [low|medium|high — the highest expected among its children]
---

<!-- At initiative slicing, the envelope: frontmatter, Description, Outcome, Done when, Boundaries, References, and known Notes. Requirements are completed at this epic's inception, and its children are planned in `tickets.toml` beside this file. -->

# [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 the owner runs at the end, taken from the source's measures and limits. Closing every child is not one.]

## 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 is not yet known and which entries wait on it, recorded 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]
- Source conflict: [id or section — what the source says vs what the code or another source shows]
- Waits on [epic] because: [the one-line reason for each entry in after]

<!-- 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. Notes holds decisions and unknowns. -->

```markdown
---
type: epic
title: "Shoppers manage their cart"
parent: initiative-checkout
covers: [R1, R2, R3, R4]
assignee: ""
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.

## 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/prd-checkout.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; entry 04 waits on it.
```
assets/initiative-template.md›
---
tracker_id: ""   # with remote (the url), written at publish on a tracker; cut on the repo store
key: ""   # tracker project or team for everything under this, when it differs from the store's
type: initiative
title: "[The outcome this container exists to reach]"
parent: none
covers: [capability ids from the spec at this level, or from Requirements below when the source has none; a constraint is cited in References, not covered]
after: []   # epics this whole container waits on; the order of epics is in the initiative's tickets.toml
assignee: ""   # status is added when work starts (in-progress | done | dropped)
risk: [low|medium|high — the highest expected among its children]
---

# [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 the owner runs at the end, taken from the source's measures and limits. Closing every child is not one.]

## 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. Then one line per unit the work touches that gets no epic, and the tracer path across epics in one sentence.]

- Touch point: [unit] — [what is consumed or configured there]; owner: epic-[slug]

## 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 is not yet known and which entries wait on it, recorded 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]
- Source conflict: [id or section — what the source says vs what the code or another source shows]
- Waits on [epic] because: [the one-line reason for each entry in after]

<!-- 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. The epic order, with what each needs from the one before, is in `tickets.toml` beside this file. -->

```markdown
---
type: initiative
title: "Checkout that shoppers finish"
parent: none
covers: [C1, C2, C3, C4, C5, C6, P1, P2, T1]
assignee: ""
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.

## 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. Tracer path: one catalog item priced, carted, taxed, and paid by a signed-in shopper.

- Touch point: notification service — a new order-confirmation template, no code change; owner: epic-tax-and-payment

## 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/prd-checkout.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 answer.
```
assets/spike-template.md›
---
id: [the entry's id in tickets.toml; the folder's `next_id` for a ticket with no entry]   # tracker_id and remote are written at publish on 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: []
after: []   # prerequisites only: a sibling's id; "<epic id>.<entry id>" in another epic; epic-<slug> for that whole epic
assignee: ""   # a tracker's assignee, written by `tickets.py mirror`; otherwise assignee, blocked_at, and blocked_reason live in the plan
# status lives in the plan beside this file, not here; tracker_status by a tracker sync
refined: false   # true once the user approves its full criteria; a pulled spike has this line only when its entry says `refine = true`
hitl: true
risk: [low|medium|high]
---

<!-- The refined shape. A done or dropped ticket stays as it was written. -->

# [Title]

## Description

[The question, why it is open, and which tickets wait on the answer.]

## 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: 2
type: spike
title: "How do field devices merge conflicting observations after days offline?"
parent: epic-field-sync
covers: []
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: [the entry's id in tickets.toml; the folder's `next_id` for a ticket with no entry]   # tracker_id and remote are written at publish on 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; the ones this ticket delivers toward]
after: []   # prerequisites only: a sibling's id; "<epic id>.<entry id>" in another epic; epic-<slug> for that whole epic
assignee: ""   # a tracker's assignee, written by `tickets.py mirror`; otherwise assignee, blocked_at, and blocked_reason live in the plan
# status lives in the plan beside this file, not here; tracker_status by a tracker sync
refined: false   # true once the user approves its full criteria; a pulled story has this line only when its entry says `refine = true`
hitl: false
risk: [low|medium|high]
---

<!-- Pulled: a one-sentence Description, Acceptance Criteria as one Verify: line, References, local Notes. Reviewed: the same, gone over with the user. Refined, with numbered criteria and Boundaries: only a ticket with no epic, an entry with `refine = true`, or on request. A done or dropped ticket stays as it was written. -->

# [Title]

## Description

[One sentence, what this delivers; gone over with the user at review. With numbered criteria: what exists or works when this is done and how it advances the epic, 2–4 sentences; the criteria carry the proof, do not restate them.]

## Acceptance Criteria

[One line, `Verify: how it will be checked`, nothing else. No epic, `refine = true`, or on request: the numbered criteria below instead.]

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, answered as part of its work; one that touches siblings goes in the parent's Notes]

<!-- 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; 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. Numbered criteria because its entry says `refine = true`. -->

```markdown
---
id: 4
type: story
title: "A shopper applies a discount code and sees the new total"
parent: epic-cart-rules
covers: [R2, R3]
after: [3]
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.
```
assets/tickets-template.toml›
# tickets.toml — a container's agreed children. tickets.py reads it.
# The order of the tables is the build order: move a table to reorder. `id` names a child for good and is never reused; it is not an order.
# An epic's file holds [[entry]] tables. An initiative's holds [[epic]] tables; with no epics it holds [[entry]] tables.
# Keys tickets.py does not know are ignored, so a team may add its own.
# Status never lives here: an entry with no plan and no leaf file is planned; its plan beside it carries `status`, and on a tracker `tickets.py mirror` writes `tracker_status` in the leaf file.

# ---- In an epic folder: one table per planned story, spike, or bug ----

[[entry]]
id = 1                      # the leaf file carries it as `id:`; refer to it as 1 here, as <epic id>.1 from another epic
type = "story"              # story | spike | bug
title = "Cart service scaffold"
description = "Stands up the cart service with one add-item path through UI, API, and store."
verify = "A shopper adds one item on the deployed site and sees it in the cart."
covers = ["R1"]             # ids from the epic's requirement source
after = []                  # real prerequisites only, never the sequence; empty means it can run beside anything above it. A bare number is a sibling's id; everything else is quoted
hitl = true                 # a person runs the starter
risk = "low"                # low | medium | high
# estimate = 2              # only when the user gives one; never proposed
# unknown = "..."           # a question to settle with the user before this entry starts, one sentence; `tickets.py next` lists the entry under blocked until it is removed
# references = ["architecture-checkout/architecture-checkout.md#ad-8"]   # what this entry needs beyond the epic's own References; pull writes them to the ticket
# notes = ["..."]           # the user's words only, never filled by the agent; pull writes them to the ticket's Notes
# refine = true            # refine to full acceptance criteria before it starts
# plan_checkpoint = true    # a person reviews the ticket, or the builder's plan, before work starts
# done_checkpoint = true    # work pauses after this ticket until a person says continue

[[entry]]
id = 2
type = "story"
title = "Apply and refuse discount codes"
description = "Adds code validation to the pricing call and shows the refusal reason."
verify = "A valid code lowers the total; an expired one is refused with its reason."
covers = ["R2", "R3"]
after = [1, "1.3"]          # a sibling's id; "<epic id>.<entry id>" for an entry in another epic; "epic-<slug>" for that whole epic
hitl = false
risk = "medium"

# ---- In an initiative folder: one table per epic; table order is the build order ----

[[epic]]
id = 1                                      # entries in other epics refer to this epic's entries as 1.<entry id>
slug = "epic-pricing-rules"                 # the epic's folder name
title = "Pricing rules"
covers = ["P1", "P2"]

[[epic]]
id = 2
slug = "epic-cart-rules"
title = "Shoppers manage their cart"
covers = ["C1", "C2", "C3"]
after = [{ epic = 1, needs = "the one-function pricing contract" }]   # what this epic needs from which; the entries that wait on it carry the `after`
bmod.toml›
[skill]
bmod = "bmod-method"
source = "github:bmad-code-org/BMAD-METHOD/skills"
scripts = ["scripts/tickets.py"]
config/github-ticketing.toml›
# Store starter: GitHub Issues. `scripts/read_store.py` reads this file as the base layer; the project's
# _bmad/custom/ticketing-store-config.toml names the store, fills the empty fields below, and replaces
# any other key here, key by key.
# It updates with the skill and holds only what is specific to this store: the rules every store shares
# are in references/board.md. Comments and `description` are never loaded; `description` is for choosing a store.
#
# 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.

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"
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. Needs gh 2.94+; check `--help` when a flag is doubted.
"""

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 = """
GitHub renders urls as links; nothing more to do.
"""

[verbs]
setup = """
Connect, unless `gh auth status` already shows a login: `gh auth login`.
Labels must exist before they are used. List the repo's labels (`gh label list --limit 1000 --json name`)
and run `gh label create <name>` for each of these it lacks, comparing names ignoring case: the
[tickets.types] values except initiative (a milestone), the [tickets.status] values except done and
dropped (closed states), `hitl`, risk:low, risk:medium, risk:high, and P0-P3. A create that fails with
"already exists" found the label after all: leave it. Never `--force`: it recolours a label the repo
already has.
"""

write = """
One issue per new ticket. tracker_id is the issue number and remote its url.
Create with content only: `gh issue create --title <t> --body-file <b> --label <type>,<[tickets.status].<state>>[,hitl,risk:<r>,P<n>]
[--milestone <initiative>]`, and mirror tracker_id and remote from the url it prints before anything else. Then add the
relations with `gh issue edit <n> --parent <n> --add-blocked-by <n,n>`, leaving out any that `gh issue view <n> --json
parent,blockedBy` already shows, since adding one twice fails. Keep --parent and --blocked-by off the create: gh adds them
after the issue exists, and when that step fails it exits non-zero without printing the url, so a rerun files the ticket
twice. A create that fails with no url may still have made the issue: before creating again, list the newest issues
(`gh issue list --state all --limit 100 --json number,title,body,createdAt`) twice, a few seconds apart since the list can
trail a create, and take the one whose title and body are what was sent. None matches and the list does not reach back to
the failed create: ask before creating again. 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.
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.
"""

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]; after is blockedBy; 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. An initiative's issues are the ones in its milestone; a standalone ticket has no milestone.
"""

# 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 state -> this store's status.
[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›
# Store starter: Jira Cloud. `scripts/read_store.py` reads this file as the base layer; the project's
# _bmad/custom/ticketing-store-config.toml names the store, fills the empty fields below, and replaces
# any other key here, key by key.
# It updates with the skill and holds only what is specific to this store: the rules every store shares
# are in references/board.md. Comments and `description` are never loaded; `description` is for choosing a store.
#
# 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.

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."

[tickets]
store = "jira"
key = ""    # the default Jira project key, e.g. "SHOP"; Jira assigns the number. A container's own `key` overrides it for everything under it
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.
"""

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 on a story and a label est:<value> on an epic, through --from-json. Empty field: default priority, no label.
"""

reference = """
acli creates no web links; the description line is the reference.
"""

[verbs]
setup = """
Connect, unless acli is already installed and signed in to `site`: install acli (on macOS,
`brew tap atlassian/homebrew-acli && brew install acli`; elsewhere, per Atlassian's acli install
guide), 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 in Jira or which map entry to set in the project's store config. Jira's defaults (To Do, In
Progress, Done; resolution Won't Do) need no setup; [tickets.status] also expects 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 = """
The project is the ticket's own `key`, else that of the nearest container above it, else `key` here.
tracker_id is the work item's key and remote its url.
Create:
`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 <[tickets.status].<state>>` —
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.
Epics sit loose in the project while [tickets.types].initiative is empty. Set that type only when the
project has a level above Epic; then epics take it as --parent.
"""

query = """
One work item: `acli jira workitem view <key> --json --fields
key,issuetype,summary,status,assignee,parent,labels,issuelinks,resolution,description`. after 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.
"""

# 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 state -> this store's status.
[tickets.status]
backlog = "To Do"
in-progress = "In Progress"
review = "In Review"   # not a default Jira status; `setup` asks for it. With no review status, set it to the in-progress one
done = "Done"
dropped = "Done"   # Jira default has no separate status; resolution Won't Do distinguishes it
config/linear-ticketing.toml›
# Store starter: Linear. `scripts/read_store.py` reads this file as the base layer; the project's
# _bmad/custom/ticketing-store-config.toml names the store, fills the empty fields below, and replaces
# any other key here, key by key.
# It updates with the skill and holds only what is specific to this store: the rules every store shares
# are in references/board.md. Comments and `description` are never loaded; `description` is for choosing a store.
#
# 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.

description = "Linear through its MCP server: projects, parent issues, relations, labels. For product teams on Linear."

[tickets]
store = "linear"
key = ""    # Linear team key, e.g. "SHOP"; Linear assigns the number
team = ""   # the default team, key or name. A container's own `key` overrides it for everything under it
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, and the exact tool names against the server's list at first use.
"""

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:<value> on an epic. Empty field: no priority, no label.
"""

reference = """
Also pass the url in the issue's links so it shows in the sidebar.
"""

[verbs]
setup = """
Connect, unless the Linear tools already answer: add the MCP server https://mcp.linear.app/mcp to
the agent tool in use (in Claude Code, `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 which map entry to set in the project's
store config. Linear's defaults (Backlog, Todo, In Progress, Done, Canceled) need no setup; [tickets.status]
also expects an In Review state in the Started group.
"""

write = """
The team is the ticket's own `key`, else that of the nearest container above it, else `team` here.
tracker_id is the issue's identifier and remote its url.
`create_issue` creates, `update_issue` changes — title, description, state, assignee, parent,
blockedBy, labels, priority, project. The issue's state is [tickets.status].<state>. Labels carry type, hitl, and
risk:<value>; priority carries severity per `fields`. A standalone ticket: omit parent and project.
Epics are parent issues. Set [tickets.types].initiative to map onto a Linear Initiative only when the
server exposes initiative tools.
"""

query = """
One issue: `get_issue` with includeRelations — labels, priority, state, assignee, parent,
project, relations, description; after 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.
"""

# 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 state -> this store's status.
[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. With no review state, set it to the in-progress one
done = "Done"
dropped = "Canceled"
config/notion-ticketing.toml›
# Store starter: Notion. `scripts/read_store.py` reads this file as the base layer; the project's
# _bmad/custom/ticketing-store-config.toml names the store, fills the empty fields below, and replaces
# any other key here, key by key.
# It updates with the skill and holds only what is specific to this store: the rules every store shares
# are in references/board.md. Comments and `description` are never loaded; `description` is for choosing a store.
#
# 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.

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"
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; `notion-get-users` resolves a name.
An empty `database` means `setup` has not run.
"""

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 = """
Notion renders urls as links; a Notion page url becomes a mention.
"""

[verbs]
setup = """
Connect, unless the Notion tools already answer: add the MCP server https://mcp.notion.com/mcp to
the agent tool in use (in Claude Code, `claude mcp add --transport http notion https://mcp.notion.com/mcp`),
then sign in over OAuth. Either way, 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 the project's store config.
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.
"""

write = """
tracker_id is the ID property when `key` is set, else the page id; remote is the page url.
`notion-create-pages` into `database` creates; `notion-update-page` changes any property or the
content — Status, Assignee, Parent, Blocked by, Covers, Risk, Severity, hitl. Status is [tickets.status].<state>. No parent: leave the Parent relation empty. An initiative
is a page of Type initiative with no Parent. The body is the page content.
"""

query = """
One page: `notion-fetch` — Title, Type, Status, Assignee, Parent, Blocked by, Covers, Risk,
Severity, hitl, and the content; after is Blocked by. Many: `notion-query-data-sources` on `database` filtered by any
property; `notion-search` scoped to `database` for a looser match.
"""

# 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 state -> this store's status (Status property options; `setup` creates them).
[tickets.status]
backlog = "Backlog"
in-progress = "In Progress"
review = "Review"
done = "Done"
dropped = "Dropped"
config/repo-ticketing.toml›
# Store starter: tickets as files in the repo, the default. `scripts/read_store.py` reads this file as the
# base layer; the project's _bmad/custom/ticketing-store-config.toml names the store and replaces any key
# here, key by key. With no project file this starter is the whole config.
# It updates with the skill and holds only what is specific to this store: the rules every store shares
# are in references/board.md. Comments and `description` are never loaded; `description` is for choosing a store.
#
# Tickets and artifacts live together as markdown files under `output_folder` (layout in SKILL.md,
# The ticket tree), 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.

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"

access = """
Read and write the files under `output_folder`.
"""

fields = """
risk and severity live in the frontmatter (risk: low|medium|high on any ticket; severity: P0-P3
on bugs); estimate the same way. Nothing else carries them.
"""

[verbs]
setup = """
Tickets live in `output_folder` beside the documents. To keep them outside the project, set `output_folder`
under `[core]` in `_bmad/custom/config.toml` to a 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 `backlog/` and the active initiative's folder there when they do not exist.
"""

write = """
Every change is an edit to the ticket's file, its `tickets.toml` entry, or, for a leaf's status and assignee, its plan through `tickets.py mark`.
Commit only the files you wrote on the current branch. Root not in a git repo: skip the commit and say so.
"""

query = """
The files are the store: `tickets.py find`, `next`, and `status` answer from them. By a frontmatter field: `grep -rl "^type: bug" <folder>`.
"""

# BMad type -> this store's type.
[tickets.types]
initiative = "initiative"
epic = "epic"
story = "story"
spike = "spike"
bug = "bug"

# BMad state -> this store's status.
[tickets.status]
backlog = "backlog"
in-progress = "in-progress"
review = "review"
done = "done"
dropped = "dropped"
config/trello-ticketing.toml›
# Store starter: Trello. `scripts/read_store.py` reads this file as the base layer; the project's
# _bmad/custom/ticketing-store-config.toml names the store, fills the empty fields below, and replaces
# any other key here, key by key.
# It updates with the skill and holds only what is specific to this store: the rules every store shares
# are in references/board.md. Comments and `description` are never loaded; `description` is for choosing a store.
#
# 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.

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"
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.
"""

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 = """
Attachments are not in the MCP yet; the description line is the reference.
"""

[verbs]
setup = """
Connect, unless the Trello tools already answer: add the MCP server https://mcp.trello.com/v1 to
the agent tool in use (in Claude Code, `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 = """
tracker_id is the card's short link and remote its url.
`trelloWriteCard create` makes a card, `update` changes description or labels, `move` changes
list, `archive` is dropped. Status is the list [tickets.status].<state>; 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), after as
card urls; the parent card keeps a checklist with each child's url. A standalone ticket: no parent line.
The body is the description, and the `assignee:` line stays in it.
"""

query = """
One card: `trelloReadCard get` by url — name, list, labels, description (assignee, parent,
covers, after live there), checklists; a container's children are its checklist items. Many:
`trelloSearch` scoped to `board`, or `trelloReadBoard` filtered by the maps.
"""

# BMad type -> this store's type.
[tickets.types]
initiative = "initiative"
epic = "epic"
story = "story"
spike = "spike"
bug = "bug"

# BMad state -> this store's status.
[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-ticket.
# Team overrides:     {project-root}/_bmad/custom/bmad-ticket.toml
# Personal overrides: {project-root}/_bmad/custom/bmad-ticket.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 = ""

# The keys below are settings a team picks. The method — how an initiative and an epic are sliced, what a
# good acceptance criterion is, how risk is scored, what the checks are — is in references/ and is not a
# key. A team rule on top of the method goes in `persistent_facts`, or in the project's AGENTS.md when the
# build skills need it too (the risk definition, the criteria rule).

# When tickets publish to a tracker; on the repo store, committing the approved tickets.toml is the publish. at_inception: the whole
# agreed breakdown at inception, so the team sees the plan there; on_start: each ticket when it starts. The user can override.
publication = "at_inception" # at_inception | on_start

# How this team draws epic boundaries. Empty: the rule in references/slice.md. Set, it replaces that rule:
# the team's own text, or a pointer to where its boundaries are recorded — a url, AGENTS.md, a team map,
# anything the agent can read.
slice_to_epics = ""

# The team's own validation checks, one array per scope, run with the skill's in references/validate-checks.md.
# Write `checks.<scope> = [...]`; an override that sets `checks` as one string replaces the whole table.
checks.ticket = []         # one ticket
checks.set = []            # every entry in an epic's breakdown
checks.tree = []           # the initiative and its epic envelopes
checks.dependencies = []   # missing prerequisites, run with the set and with the tree
checks.closure = []        # a container before it closes

# Ticket templates, one per type. They apply to tickets the agent writes; `tickets.py pull` writes a fixed layout,
# so a pulled story does not follow `story_template`.
# Swap a template to change the criteria format (the shipped one is numbered Given/When/Then; a table, a checklist)
# or to add your own sections. A container's envelope fields and its number of Done when checks are the container
# templates' too.
# 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, and Done when; Reproduction and Cause Hypothesis for bugs, Approach for spikes. `Decision:` and `Dropped:` lines live in Notes.
# Spikes use "Verify:" lines instead of Given/When/Then, and a pulled story carries one Verify: line unless its entry has `refine = true`; 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"
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.

`tickets.py` below is `uv run {skill-root}/scripts/tickets.py --project-root {project-root}`. `tickets.py <command> --help` gives a command's arguments and output keys.

## Reading the tree

- A ticket the user names — "refine 1.2", "start ABCD-13", "build the cart service scaffold" — resolves through `tickets.py find [<folder>] <ref>` before you pull, review, refine, start, or mark it. `<ref>` is `<epic id>.<entry id>`, an entry id inside the epic folder, a tracker id, a file name, or an unbroken phrase from the title. When the user's words are looser than that, read `status` and pick the ticket yourself, asking when more than one fits. In the result, `story_file` is null until the entry is pulled, and `plan` is where the plan is or goes.
- Progress lives on tickets, not in a separate sprint or status file. `tickets.py next [<folder>]` groups the open tickets by what can happen next; `tickets.py status [<folder>]` reports every ticket with its `status`, `tracker_status`, and `state`, what it blocks, and counts by state.
- `<folder>` is an epic (its folder name is enough), `backlog`, or the initiative for all its epics at once. Left out, it is the active initiative: `next` and `status` add `backlog`, the same view of `backlog/`, so show both, and `find`, `mark`, and `mirror` look in `backlog/` for a ticket the initiative does not hold. A done standalone ticket stays in `backlog/` beside its plan.
- With a tracker, bring the tree up to date first (Tracker sync, below) and pass `--synced` to `next`.

When one of these comes back with something in it, act on it:

| Key | Means | Do |
|---|---|---|
| `unpinned_after` | an epic has tickets, but none waits on the epic its `after` names | with the user, add the prerequisite to the entry that needs it |
| `undeclared_after` | an entry waits on an epic its own epic does not declare | with the user, add that epic to this epic's `after` in the initiative's breakdown with its `needs`, or drop the prerequisite |
| `order_conflict` | an epic waits on one later in build order | reorder or merge the epics, or record why in a `Decision:` line |
| `drift`, on a row | the leaf file's `after` or `hitl` differs from the entry's; the row gives both values | show them to the user and make them equal |
| `problems` | a plan whose `ticket` names nothing, or whose `status` is unknown, so its ticket reads as blocked | show each to the user and fix the plan; `mark` sets a valid status |
| `waiting_on`, on a `blocked` row of `next` | the prerequisites not yet done or in review | say what the ticket waits on |
| `unknown`, on a `blocked` row of `next` | a question that must be settled before the ticket starts | settle it with the user, remove it, and record the answer as a dated `Decision:` line: in the file's Notes, or in its epic's Notes when it has no file |

## Publish

Approving a breakdown records it in the epic's `tickets.toml`; it does not start work. On the repo store, committing the approved `tickets.toml` is the publish. On a tracker, `{workflow.publication}` says when: `at_inception` offers to publish the agreed set after validation; `on_start` publishes each ticket when it starts. An explicit user request takes precedence.

Publishing is not a status: on a tracker it is the remote item existing, with `tracker_id` and `remote` in the leaf file. Publish through `write`:

- The leaf's file is written first, with `tickets.py pull` when it has none, because the file is the body the tracker receives: the whole file without the frontmatter lines `tracker_id`, `remote`, `tracker_status`, `status`, and `assignee`.
- Create parents before children, and a prerequisite before what waits on it. A prerequisite that is still local joins the proposed publication scope. An `after` naming a sibling's id or `<epic id>.<entry id>` is that entry's remote item; `epic-<slug>` is the epic's item.
- 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 the tracker lacks something the maps or `fields` name — a label, list, status, or property — offer `setup`.
- Then mirror what the tracker returned (Tracker sync). `mirror` writes leaves only: a published container's `tracker_id` and `remote` go into its own file by edit.

An unrefined ticket may be published for planning visibility. One that needs refining keeps `refined: false`, and that line stays in the published body; the `backlog` state means available to start. Refinement updates that same file and remote item, preserving identity.

## Start

Candidates are `next`'s `ready_to_start` rows: tickets, with or without a file, that are not started, blocked, or waiting to be refined, whose prerequisites are done or in review, whose `gated_by` epics are done, and that carry no `unknown`. Offer the first, in build order; when work can run in parallel, offer every one that is unassigned.

Before starting a candidate, read it and its source, passing its row's `ref` to `find`. One in `ready_to_refine` is refined first per `ticket.md`. Confirm it is in `ready_to_start` and not assigned to someone else. Then, with the user's approval, publish it if it is not yet published, and start it. On a tracker, `write` transitions the item to `[tickets.status].in-progress` and the mirror sets `tracker_status: in-progress`. On either store the ticket then goes to the build, `bmad-build` or `bmad-build-auto`, which writes `status` in the ticket's plan as it works; on the repo store that is all starting is. An entry's `plan_checkpoint` waits for a person to approve the ticket, or the builder's plan when it is not refined, and `done_checkpoint` waits after the ticket closes. Both are on the ticket's row, and the orchestrator that dispatches an unattended run does the waiting: `bmad-build-auto` reads neither, so run without an orchestrator nothing pauses.

## Status

A leaf's `status`, `blocked_at`, and `blocked_reason` live in its plan, `<type>-<slug>-plan.md` beside where its file is or would be, joined by the plan's `ticket` field; a leaf file's own `status` is read only when there is no plan. Who writes each status:

| Status | Written by |
|---|---|
| `draft`, `ready-for-dev`, `in-progress`, `in-review`, `built` | `bmad-build`, `bmad-build-auto` as they work |
| `blocked` | `bmad-build-auto` when it halts, with the reason in the plan |
| `done` | the user, or an orchestrator, through `tickets.py mark` |
| `dropped` | `bmad-ticket`, when the user says so |

A ticket's state is `planned` for an entry with no file and no plan; else `tracker_status` when present; else from `status`: none, `draft`, or `ready-for-dev` is `backlog`; `in-progress` or `blocked` is `in-progress`; `in-review` or `built` is `review`; `done` and `dropped` are themselves. `tracker_status` exists only on a tracker store: the BMad word for the tracker's current status.

No skill moves a ticket past `built`, which means the build finished and nobody has called it done. `bmad-ticket` never moves a leaf on its own: it changes a leaf's status or assignee only when the user asks, for `done`, `dropped`, or a person working the ticket by hand. When the user says a ticket is done, mark it for them.

- **Repo store.** `tickets.py mark [<folder>] <ref> <status> [--assignee <who>] [--blocked <reason>]`, then the commit the `write` verb describes. `mark` takes its folder and ref as `find` does, writes the plan and never the leaf file, and creates a plan holding only frontmatter when there is none. It writes what it is told; the checks in this file are yours. The assignee is in the plan.
- **Tracker.** `write` transitions the item to `[tickets.status].<state>`, the ticket's `state`, and sets its assignee; it never sends `status` as a word. Then mirror the item (Tracker sync): its status comes back as `tracker_status` and its assignee lands in the leaf file. `mark` refuses a tracker store.
- **Waiting on a person or an answer**, not on a prerequisite, on the repo store: `mark` with the ticket's current status, `draft` when it has none, and `--blocked <reason>` sets `blocked_at` (today) and `blocked_reason`, and `next` lists the ticket under `blocked`; a `mark` without it clears both when the ticket moves.
- **A container's** `status` is `bmad-ticket`'s, an edit to its file: absent until the first ticket under it starts, then `in-progress`, and `done` or `dropped` when the user confirms it; containers never take review. A published container gets the same transition through `write`.

## Close, drop, move

- 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 says why: in the ticket's Notes, or in its epic's Notes when the ticket has no file. Then set its status to `dropped` as Status says; an entry never published to the tracker takes `tracker_status: dropped` through `mirror`. An entry is never deleted from an approved `tickets.toml`, so its id is never reused. A dropped ticket still blocks what waits on it, in any epic: `status` lists them under `blocks`; remove or repoint it in each one's `after` with the user. Cancelling a container cancels its descendants after the user confirms.
- Moving a leaf to another parent: first write the old folder's `next_id` (from `tickets.py status <folder>`) at the top of its `tickets.toml` as `next_id = <n>`, so the id that leaves is not given out again. Then move its entry from the old `tickets.toml` to its place in the new one's build order, with that folder's `next_id` (from `tickets.py status <folder>`) as its id; move the leaf file and its plan into the new folder; set the file's `id` and `parent` and the plan's `ticket` to the new id. Then rewrite both directions of `after`: its own, where a sibling it left is now `<epic id>.<entry id>`, and every `after` that named it. Move its `covers` ids between the two epics when the requirement moves with it. An epic moves as its whole folder, with its `[[epic]]` table. Then run `tickets.py status` on the initiative and clear what it reports.

## Tracker sync

The tree is the working copy and a tracker is its remote; nothing syncs on its own. After every `write`, and after the `query` that precedes a board view, put what the tracker holds into the tree with `tickets.py mirror [<folder>]`. It reads a JSON array on stdin, one object per ticket: `ref` (or the `tracker_id` the ticket already carries) and any of `tracker_id`, `remote`, `tracker_status`, `assignee`, and `after`. `tracker_status` is the BMad state the tracker's status maps back to through `[tickets.status]`. After a `write`, it is the state that was sent. After a `query`, when two states share one tracker status and `query` does not say how to tell them apart, keep the ticket's current `tracker_status` if it is one of them, else take the earlier state. `assignee` is the tracker's name for the person; `after` lists the prerequisites as tracker ids, each a string, since a bare number there is a sibling's entry id. `mirror` pulls a leaf file when the entry has none, writes only the keys given, removes a line whose value is an empty string, and never writes `status`, which is the build's.

- A leaf the tracker holds and the tree does not comes back under `unmatched`, also when the mirror failed because a known ticket's `after` names it: add its `[[entry]]` to the parent's `tickets.toml` with the folder's `next_id` and the item's type, title, and a one-sentence description, mirror it again with `ref` naming the new entry, then put the item's body in the pulled file.
- A published container is queried with its leaves, and `mirror` does not write containers. When the tracker's status for one differs from its file's, tell the user; closing it here still takes the closure check.
- A body that differs from the file: show both and ask.
- A ticket the tracker shows under another parent is moved as Close, drop, move says, then mirrored again.
- Commit the changed files on the current branch when `output_folder` is in a git repo; never push.
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. The user's preference for a split comes first.

When an initiative has epics, every story is under an epic. Work that fits one epic is proposed as one epic; say so. Never offer an initiative without epics: it happens only at the user's request, is planned like an epic with stories directly underneath, and you say once that the initiative folder fills with ticket files.

## 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 template's Requirements placeholder says what the section holds. A numbered spec anywhere in the container's folder is the source instead. Offer `bmad-spec` only when the source outgrows the section or the user asks, and pass it the container's folder: its `spec-<slug>/` goes inside that folder, 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 `ticket.md`: the requirement source, constraints as References, known unknowns in Notes, and a re-read of Outcome and Done when against the source. The product owner and developer agree on its scope: propose the fields together with reasons, discuss what is unsettled rather than repeating decisions already made, and confirm before slicing.

## 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 work will touch, enough to see which stories would change the same code. 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.

Where the source contradicts the code or another source, add `Source conflict: <id or section> — <what the source says> vs <what was found>` to the Notes of the container whose source it is, and tell the user. When the source is a BMad spec, offer to pass the correction to `bmad-spec`.

## 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.

When the user states how their team cuts epics, offer to save it as `slice_to_epics` under `[workflow]` in `{project-root}/_bmad/custom/bmad-ticket.toml`.

Record each choice the user makes while slicing — a boundary, the tracer bullet, the sequencing, deferred scope, a declined suggestion — as a dated `Decision:` line in the container's Notes when it is made, not after approval.

## Initiative into epics

Existing epics are the working set: read them first and revise them in place, or drop one per `board.md` when it no longer fits; add only after the user confirms the set is insufficient. Draw the boundaries by the team's own rule, `{workflow.slice_to_epics}`, when it is set. Otherwise:

- 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.
- Propose epics along the source's capabilities. Merge two when one owner and one module deliver both. Two epics need at most a contract between them; say what each needs from the ones before it.
- A unit (module, service, or bounded context) is an epic boundary when it is also the ownership or deployment boundary and its Done when still reads as something a product owner can check. A module that is only a code folder is not.
- An epic whose boundary names more than one outcome or owner is two epics.

Under either rule:

- No boundary applies: one epic. Split only for a distinct outcome, owner, or a part the user wants usable early, never for a ticket count.
- A unit the work only consumes or configures gets no epic: it is a touch point, named in the initiative's Boundaries with the epic that owns the work there.
- The platform baseline (scaffold, environments, CI, deployment, operations) is the opening epic, the first `[[epic]]` in the initiative's breakdown, or the first stories of the first epic.
- Every epic delivers to production; its Done when includes the integrated verification for what it delivers.

Draft the set, run the tree check in `validate.md` on the draft, then present it in recommended build order and say in one sentence which rule cut it. For each epic: 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. With the set, present:

- the tracer path across epics, when the first demo cuts through several;
- every touch point, with the epic that owns it;
- the decisions more than one epic must adopt: a contract, a message or data format, a shared value list. One repo or one unit has none.

Work with the user on order, boundaries, merges, splits, and anything unplaced.

On confirmation, write the initiative's `tickets.toml` from `{skill-root}/assets/tickets-template.toml`: one `[[epic]]` per epic in build order, each with an `id`, `after` naming what it needs and from which epic; `after` on an epic file only for a whole-epic gate. Each new epic gets its folder and envelope with Outcome and Done when.

For the decisions more than one epic must adopt, offer `bmad-architecture` to settle them in the spine, and cite the spine section in each adopting epic's References. Declined: add each as a `story` entry (`hitl = true`) in the `tickets.toml` of the opening epic, and give each adopting epic `after = [{ epic = <opening>, needs = <the decision> }]`; its entries name that entry in `after` at their inception.

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 a breakdown.

## Epic into stories

The epic is ready to be worked and its spec exists or the user chose to go without. Read its breakdown, every child ticket, and their results before proposing changes. Plan the entire epic, not only its next story. Put each unknown that must be settled before implementation to the user: answer it now, record it as the `unknown` of the entries it affects, or add a spike when they ask for one. Never guess the design of work that depends on it.

What makes a good story:

- It is one implementation step toward the epic's Done when, small enough that one agent session, starting from the entry, its epic, and the source, plans and finishes it. It need not be user-visible on its own; the epic is the unit of value. Too small when setup outweighs the work: merge. An epic that is itself one session's work gets one story.
- The first entry 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 entry. For it, and for anything the user wants demoable, the entry says what someone can see running when it is done. After the tracer bullet comes the story the user is least sure of.
- A lane is a run of stories that touch the same code, in order; stories in different lanes never touch the same code. Two that would are one story, or one waits on the other. A boundary two lanes share gets its interface and a stub as its own early story, so both lanes open at once.
- Done is one runnable check. A story whose check needs another story's work goes after it.
- Split, never shrink: "for now", "placeholder", "simplified", "wired later" is a second story.
- Setup and each hitl step go on the first story that needs them, under the relevant epic and never loose under the initiative; initiative-wide setup belongs to the opening epic. Tests are part of every story; the one test story is the closing end-to-end suite below.
- Eight to twelve stories 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 first; the user decides.

What opens and closes the breakdown:

- Offer an opening refactor when poor code quality or missing standards would make the epic's tickets hard.
- An epic of more than three entries gets a closing story "Refactor sweep" that waits on every other entry except a closing end-to-end suite. Its scope is set when it starts, from the plans and the review findings deferred during the epic. It takes cleanup only; scope pushed out of another story is a new story. Propose it by default; when the user declines, record a `Decision:` line in the epic's Notes.
- One closing end-to-end suite across the epic, after the sweep, is offered when the source has a test plan or the user wants one.

Draft one breakdown in build order, run the set check in `validate.md` on the draft, then present it, and adjust size, order, and prerequisites with the user until they approve the set. Each entry follows `{skill-root}/assets/tickets-template.toml` and has an `id` it keeps for good; the order of the tables is the build order. What the template does not say:

- `description` says what the entry delivers toward the ids in its `covers`, and `verify` how that result will be checked: one sentence each.
- `after` holds real prerequisites only, never the sequence. For each `after` on this epic in the initiative's breakdown, put the provider in `after` of every entry that needs it: `<epic id>.<entry id>` when the providing entry exists, `epic-<slug>` until it does.
- `refine = true` only where the user asks for full criteria on that entry; a bug gets them without it.
- `references` holds only what the entry needs beyond the epic's own References: a spine section, a design screen, a document. `notes` holds only what the user said, in their words.
- When the epic will run unattended, ask for `plan_checkpoint` and `done_checkpoint` per entry, proposing `plan_checkpoint` on a high-risk one.
- Each touch point this epic owns is an entry or part of one.

With the draft, name the tracer bullet, what can run in parallel, and any deferred scope. With the approval question, ask once whether the user wants to change a description or add a note or reference to any entry.

A split at inception is a second epic folder and envelope, a new `[[epic]]` 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 `tickets.toml`. No leaf file is written until its entry is pulled.

Then run `uv run {skill-root}/scripts/tickets.py --project-root {project-root} status <initiative folder>`. In `epics`, this epic's `blocks` lists the tickets in other epics whose `after` names the whole epic; replace each with the entry that delivers what it waits for. Clear any `unpinned_after`, `undeclared_after`, or `order_conflict` it reports as `board.md` says. Publication follows `board.md`.

Then tell the user the entries are ready to build, and what that means for how they build. With `bmad-build`, each ticket is refined during the build: the builder questions the user and writes the criteria itself, so refining here repeats that work. With `bmad-build-auto`, a loop, or a factory, nobody answers questions during the build and the entry is all the builder gets: recommend one more review of the sequence and of each entry now (`ticket.md`), and offer the checkpoints where none are set.

## Re-slicing

When completed work changes the picture, revisit the whole remaining breakdown with the user. Update unstarted entries and tickets in place, preserving each `id`; published changes go through the store. Do not rewrite completed or active work as a new breakdown. Run the set check on the revised draft before the user approves it, and record the reason in the epic's Notes.
references/store-setup.md›
# Setting up the store

The store config is two layers that `{skill-root}/scripts/read_store.py` reads as one. The base is the skill's starter for the store, `{skill-root}/config/<store>-ticketing.toml`, which updates with the skill. Over it goes the project's file, `{project-root}/_bmad/custom/ticketing-store-config.toml`: it names the store and holds only what this project sets, and a key it sets replaces the starter's. With no project file the store is the repo store, and nothing needs setting up.

Run this when the user wants a tracker connected or the store reconfigured or switched.

1. **Choose.** `uv run {skill-root}/scripts/read_store.py --starters` lists the shipped stores with a description of each. Every hosted one has a free tier; choosing it costs an account setup, or only the config when the user already has an account. The user may also bring a config of their own, as a file or a url.
2. **Write the project file.** `[tickets]` with `store` and the address fields the starter leaves empty — key, repo, site, team, database, board — filled from the user's answers; the comments in the starter file say what each one is. Copy nothing else from the starter: a key the project file leaves out follows the skill as it updates. A project file that is a whole copy of a starter predates this layout: cut it down to `store`, the address fields, and what the team changed. A config the user brings goes into the project file whole; so does a store with no starter, modelled on the nearest one. For the repo store, delete the project file.
3. **Connect.** Offer to run the store's `setup` verb: tool install, auth, and the labels, lists, statuses, or properties the maps name. Where the tracker's types or statuses differ from the starter's maps, set those entries under `[tickets.types]` or `[tickets.status]` in the project file. A state the tracker has no status for takes the status of the state before it, never an empty value: with no review status, `review` takes the in-progress one.
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, move it to `in-progress`, assign it, then drop it. Give the user the `remote` url so they can watch the item and its history.

A house rule on top of a verb, such as a label every new issue takes, goes in `persistent_facts` in this skill's customization. Set a whole verb under `[verbs]` in the project file only when the store's commands differ, because a replaced verb no longer follows the skill.

Then go on with what the user came to do. When that is done, offer once a `bmad-customize` pass on this skill: its `customize.toml` holds the team's epic boundary rule, publication timing, own checks, and templates.
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. Under an epic with a breakdown, the ticket is an entry, with the keys `{skill-root}/assets/tickets-template.toml` shows, placed where it goes in the build order; it gets a file now only when it carries full criteria, and otherwise `pull` writes one when it is reviewed or published. A standalone ticket is a file in `backlog/`. Either takes its `id` from `next_id` in `uv run {skill-root}/scripts/tickets.py --project-root {project-root} status <folder>`. 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. Reference a level's requirement 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 says what it delivers toward them. Several stories can cover one requirement. Where full criteria are written ("Reviewing or refining an existing ticket" says when), 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, where they are written, in the template's format: each is a check the implementer can prove it met without pointing at code, and together they are enough that building the wrong thing cannot pass. Behavior that must stay as it is goes under Boundaries.
- Every sentence is short and declarative, in common words and the project's own vocabulary: the reader has only the ticket. Say each thing once, with no invented term, filler, or metaphor. When the user's text misses this or the criteria rules, offer the rewrite with the reason and let the user decide; show what is missing, not only what is written.
- A reference is a line in References, `type — location, section`: location is a path from `{project-root}`, or a url for a remote source, and is opened before it is cited. Name the nearest document, not the documents behind it. `reference` in the store's `tickets` table, read at activation, says what else that store needs; the repo store has none.
- 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: `low`, `medium`, or `high`, the business impact if this change is wrong, proposed with a one-line reason; the user's value wins. A high-risk ticket names one check outside its own criteria in Notes: a person who confirms, a suite beyond the ticket's tests, a monitor. The build confirms or raises the risk in its plan, which is where a CI process reads it to decide how much review a change gets.
- Severity on a bug, as the bug template defines it. An `estimate` is recorded only when the user gives one: on the entry or in the ticket's frontmatter, carried to a tracker as the store's `fields` says. Never propose one.
- 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`. When tickets in more than one epic wait on the answer, it is a decision several epics adopt: handle it per `slice.md`, not as a spike inside one of them.
- 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; an unknown that gates work goes to the user, and becomes a spike its dependents list in `after` only when they ask for one. `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. `Unknown:` — it gates the start, and `tickets.py next` lists the ticket under `blocked` while the line is there; settle it with the user before the build begins, as a dated decision. Never resolve any of them by guessing.

## Reviewing or refining an existing ticket

Resolve what the user names with `uv run {skill-root}/scripts/tickets.py --project-root {project-root} find [<folder>] <ref>`: `<epic id>.<entry id>`, an entry id inside the epic folder, a tracker id, a file name, or an unbroken phrase from the title. When its `story_file` is null, pull the file with `tickets.py pull <epic folder> <id>` (same `--project-root`); `pull` writes a fixed layout, not the template. A done or dropped ticket and its plan are the record: do not reshape them to a newer template.

Full acceptance criteria are written here only for a bug, a ticket with no epic, and an entry that carries `refine = true`, which is set when the user asks for full criteria on that entry and is never proposed for a story. `tickets.py next` lists these under `ready_to_refine`. An epic's other stories go to the build as they are: the builder questions the user and writes the criteria into its plan. They are reviewed only when the user asks, and a request to refine one of them is a review unless the user asks for full criteria.

Where a change is written:

| Change | Written to |
|---|---|
| description, `Verify:`, criteria, references, notes | the file |
| `title`, `type`, `after`, `hitl` | the file and the entry both; the file's `after` and `hitl` win, and `tickets.py status` reports a difference in either as `drift` |
| full criteria asked for on a story | `refine = true` on the entry, `refined: false` in the file |
| order | the tables in `tickets.toml` |

Once the ticket is published, every change also goes through `write`.

**Review** — the next story by default, several or all on request. Read the file, its source, and the finished siblings and their plans. With the user, improve the description, `Verify:`, references, and notes; adjust order and prerequisites, and run the set check in `validate.md` on the revised draft when they change. A review sets neither `status` nor `refined`: the file is intent that is ready for the build, which plans the story's criteria from the epic's Requirements and Done when and the story's description and `Verify:` check, which it may extend and never weaken.

**Refine** — before the ticket starts. Read the ticket, its source, the finished siblings and the plans beside them, and the code it will touch at the revision the work starts from. Where the source contradicts the code or another source, add `Source conflict: <id or section> — <what the source says> vs <what was found>` to the Notes of the container whose source it is and tell the user, offering to pass the correction to `bmad-spec` when the source is a BMad spec; a ticket covering that id stays `refined: false` until a `Decision:` line settles it. Confirm its prerequisites are done or in review, resolve questions that prevent implementation, and expand it per its type's template and "Rules the template cannot carry": criteria, boundaries, references, and decisions. Set `refined: true` when it passes self-review in full and the user approves it.

**A ticket that already carries full criteria, or a container** — read it and open what it references; query its remote state if it is published to a tracker. With the user: confirm they still agree with it; check its description against the current requirement source, criteria and references; find what is missing, unclear, or wrong; settle questions that prevent implementation. A container may end in a re-slice per `slice.md`.

A reply approves what was presented; publication and starting work are approved separately unless the user asks for them together. None of these changes status or assignee: preserve identity and any existing `status`, `tracker_status`, and assignee.

## Self-review before the user sees it

Read it back at its current level of detail: an entry needs its description and verification approach; a refined ticket needs runnable acceptance criteria and settled prerequisites. Check size (one agent session plans and finishes it), wording, references, and source consistency. Fix what you find; mention changes to the user's intent.

## After approval

On the repo store, save a new or changed ticket through the `write` verb: the commit it describes. On a tracker the ticket stays in the tree until it is published, a separate approval, per `board.md`. A ticket written or refined with full criteria carries `refined: true` once the user approves it, and gets the single-ticket check in `validate.md` before any build starts it.
references/validate-checks.md›
# Validation checks

One section per scope. `validate.md` says which sections an agent runs and what it is given.

## 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.
- An entry has a description, known uncertainty, and a `verify` check someone other than the builder can run. Full criteria are required before execution only for a bug, an entry with `refine = true`, and a ticket with no epic.
- A ticket that needs full criteria and whose `covers` includes an id named in a `Source conflict:` line with no later `Decision:` settling it has `refined: false`.

## Set

The set is every entry in the epic's `tickets.toml` or draft breakdown, pulled 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; several tickets may cover one id, each description saying which part it delivers.
- 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.
- Every `after` is a real prerequisite, those in other epics included, and none restates the order. Once `tickets.toml` exists, `tickets.py status` on the epic runs clean: no cycle, no `drift`, no `unpinned_after`, `undeclared_after`, or `order_conflict`. The tracer bullet (the first entry: the thinnest path through every layer the epic touches) and the sequencing decisions are in the epic's Notes.
- Given the epic, an entry, and its references, a builder could write that story's acceptance criteria if it had to. If it could not, fix the epic's requirements or the references; the entry stays one sentence each for `description` and `verify`.
- An epic of more than three entries closes with a "Refactor sweep" story that waits on every other entry except a closing end-to-end suite, or a `Decision:` line says why not.

## Tree

The tree is the initiative and its epic envelopes, drafted or written.

- Every initiative requirement has an accountable epic or an agreed deferral. Shared requirements say which part each epic delivers; no unexplained overlap.
- 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 unit the source touches is an epic, a touch point (a unit the work only consumes or configures, named in the initiative's Boundaries with the epic that owns the work there), or named out of scope.
- Every decision two or more epics must adopt has one home: a spine section, or an entry in the opening epic (the first `[[epic]]`) that the others wait on.
- Every epic envelope has Outcome, Done when, boundaries, upstream coverage, and references; a future epic needs neither a breakdown nor a completed spec. The initiative's `tickets.toml` lists every epic in build order, and each `after` names what is needed and from which epic. It opens with a platform-baseline epic (scaffold, environments, CI, deployment, operations), or a `Decision:` line says why not. Each epic's Done when includes production delivery.
- With epics, no leaf sits directly under the initiative.

## Dependencies

Missing prerequisites, run with the set and with the tree. An item is an entry in the set and an epic in the tree; a prerequisite is an `after` on either.

- Needs: list what must exist before each item can start and before its check can run: code, schema, setup, test tooling, fixtures, an entry point, a decision, an investigation's answer. The item builds it itself, or something in its `after` delivers it.
- Collisions: two items with no dependency path between them can run at the same time, so they share no code, config, schema, or setup. Where they would, one goes in the other's `after` or they merge.
- Shared setup: whatever more than one item needs (test harness, scaffold, schema, a shared component or contract, an integration) has one owner, the earliest item that needs it, and the others list it in `after`.
- Handoffs: where one item relies on another's output (a default, an interface, a link target), both descriptions say so, so neither builder invents its own.

## Closure

- Verify the container's 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.
references/validate.md›
# Validating tickets

The checks are in `{skill-root}/references/validate-checks.md`, one section per scope: Ticket, Set, Tree, Dependencies, Closure. Agents that were not in this conversation run them; each is given that file's path and the names of its sections, and reads them there. `tickets.py` reads only the prerequisites that were written; one that is missing is for these agents to find.

- **A proposed set of epics, a proposed epic breakdown, or a re-slice:** on the draft, before the user is asked to approve it. This always runs; no path, mode, or setting skips it. First walk the draft through the Dependencies section yourself and fix what that finds, then give the agent the corrected draft as text. Present the draft with what the check changed and what it confirmed. Run it again when the user's changes add, remove, merge, or reorder items. At initiative slicing, check the tree's scope ownership without requiring stories or full detail in future epics.
- **A refined ticket:** before execution.
- **A container:** before closure, its implemented coverage and Done when.

How:

- One subagent per epic: its container, the draft breakdown or `tickets.toml`, every child ticket including completed work, requirement source and companions, and the sections Ticket, Set, and Dependencies. One subagent for the tree: the initiative, the draft or written epic envelopes, source, and the sections Tree and Dependencies. A single ticket: one subagent with its source and the section Ticket. Closure: the section Closure.
- A team's own checks are in `{workflow.checks}`, one array per scope with the same names; give each agent the entries for its sections as text.
- Where tickets carry full criteria (`refined: true`), say which they are and point the agent at "Rules the template cannot carry" in `{skill-root}/references/ticket.md` for the criteria and risk rules. Once `tickets.toml` exists, give the full `tickets.py status` command for the folder.
- Merge findings into fix (mechanical), suggest (a guideline, with its reason), or ask (needs the user). Resolve coverage gaps, missing prerequisites, 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_store.py›
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""Read the ticket store's config without loading the rest into context.

The config is two layers. The base is the skill's starter for the store, `config/<store>-ticketing.toml`.
Over it goes the project's `_bmad/custom/ticketing-store-config.toml`: its `[tickets] store` names the
store, and any other key it sets replaces the starter's, tables merging key by key. With no project file
the store is `repo`. A store with no starter is read from the project file alone.

  read_store.py --project-root <root> [-k <dotted key> ...]
  read_store.py --starters

One key prints its value, a string bare and anything else as JSON; several print one JSON object; none
prints the whole merged config. `--starters` prints each shipped store with its description.
Exit 1 when a file cannot be read, 2 when a key is missing.
"""

import argparse
import json
import re
import sys
import tomllib
from pathlib import Path

sys.dont_write_bytecode = True

_MISSING = object()
STARTERS = Path(__file__).resolve().parents[1] / "config"
PROJECT_FILE = Path("_bmad") / "custom" / "ticketing-store-config.toml"
STORE_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")


def load(path: Path) -> dict:
    # utf-8-sig: Windows editors can save a byte-order mark.
    return tomllib.loads(path.read_text(encoding="utf-8-sig"))


def merge(base: dict, over: dict) -> dict:
    out = dict(base)
    for key, value in over.items():
        out[key] = merge(out[key], value) if isinstance(value, dict) and isinstance(out.get(key), dict) else value
    return out


def store_config(project_root: Path, starters: Path) -> dict:
    path = project_root / PROJECT_FILE
    project = load(path) if path.is_file() else {}
    tickets = project.get("tickets")
    store = tickets.get("store") if isinstance(tickets, dict) else None
    if not isinstance(store, str) or not store:
        store = "repo"  # as tickets.py reads it
    starter = starters / f"{store}-ticketing.toml"
    config = merge(load(starter), project) if STORE_RE.match(store) and starter.is_file() else project
    if isinstance(config.get("tickets"), dict):
        config["tickets"]["store"] = store
    return config


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=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
    parser.add_argument("--project-root", help="project holding _bmad/")
    parser.add_argument("--key", "-k", action="append", default=[], help="dotted key (repeatable)")
    parser.add_argument("--starters", action="store_true", help="list the shipped stores and their descriptions")
    parser.add_argument(
        "--starters-dir", default=str(STARTERS), help="folder of starters; default: the skill's config/"
    )
    args = parser.parse_args()
    starters = Path(args.starters_dir).expanduser()

    try:
        if args.starters:
            found = {}
            for path in sorted(starters.glob("*-ticketing.toml")):
                data = load(path)
                found[data.get("tickets", {}).get("store", path.name)] = data.get("description", "")
            print(json.dumps(found, indent=2, ensure_ascii=False))
            return 0
        if not args.project_root:
            parser.error("--project-root is required")
        data = store_config(Path(args.project_root).expanduser(), starters)
    except (OSError, ValueError) as error:  # ValueError covers a TOML or encoding failure
        sys.stderr.write(f"error: cannot read the store config: {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__":
    if sys.platform == "win32":
        # Piped output on Windows defaults to a legacy code page, not UTF-8.
        sys.stdout.reconfigure(encoding="utf-8")
        sys.stderr.reconfigure(encoding="utf-8")
    raise SystemExit(main())
scripts/tests/test_read_store.py›
import json
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path

SCRIPT = Path(__file__).resolve().parents[1] / "read_store.py"

REPO = '''
# comment that must never be printed
description = "Files in the repo."

[tickets]
store = "repo"
access = "Read and write the files."

[verbs]
write = """
Edit the file.
Commit it.
"""

[tickets.status]
backlog = "backlog"
done = "done"
'''

JIRA = """
description = "Jira through acli."

[tickets]
store = "jira"
key = ""
site = ""

[verbs]
write = "acli create"
query = "acli view"

[tickets.status]
backlog = "To Do"
review = "In Review"
done = "Done"
"""


class ReadStoreTests(unittest.TestCase):
    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name) / "project"
        self.starters = Path(self.tmp.name) / "config"
        (self.root / "_bmad" / "custom").mkdir(parents=True)
        self.starters.mkdir()
        (self.starters / "repo-ticketing.toml").write_text(REPO, encoding="utf-8")
        (self.starters / "jira-ticketing.toml").write_text(JIRA, encoding="utf-8")

    def tearDown(self):
        self.tmp.cleanup()

    def project(self, text):
        (self.root / "_bmad" / "custom" / "ticketing-store-config.toml").write_text(text, encoding="utf-8")

    def run_script(self, *args):
        return subprocess.run(
            [sys.executable, str(SCRIPT), "--starters-dir", str(self.starters), *args],
            encoding="utf-8",
            capture_output=True,
            check=False,
        )

    def read(self, *keys):
        args = [a for k in keys for a in ("-k", k)]
        return self.run_script("--project-root", str(self.root), *args)

    def test_no_project_file_reads_the_repo_starter(self):
        result = self.read("tickets.store")
        self.assertEqual((result.returncode, result.stdout), (0, "repo\n"))
        result = self.read("verbs.write")
        self.assertEqual(result.stdout, "Edit the file.\nCommit it.\n")
        self.assertNotIn("comment", self.read().stdout)

    def test_the_project_file_names_the_store_and_its_keys_win_one_by_one(self):
        self.project('[tickets]\nstore = "jira"\nkey = "SHOP"\n\n[tickets.status]\nreview = ""\n')
        result = self.read("tickets", "verbs.query")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(
            json.loads(result.stdout),
            {
                "tickets": {
                    "store": "jira",
                    "key": "SHOP",
                    "site": "",
                    "status": {"backlog": "To Do", "review": "", "done": "Done"},
                },
                "verbs.query": "acli view",
            },
        )

    def test_a_verb_the_project_file_sets_replaces_the_starters(self):
        self.project('[tickets]\nstore = "jira"\n\n[verbs]\nwrite = "our own create"\n')
        self.assertEqual(self.read("verbs.write").stdout, "our own create\n")
        self.assertEqual(self.read("verbs.query").stdout, "acli view\n")

    def test_a_store_with_no_starter_is_read_from_the_project_file_alone(self):
        self.project('[tickets]\nstore = "azure"\n\n[verbs]\nwrite = "az boards"\n')
        self.assertEqual(self.read("verbs.write").stdout, "az boards\n")
        result = self.read("verbs.query")
        self.assertEqual(result.returncode, 2)
        self.assertIn("missing: verbs.query", result.stderr)

    def test_a_store_name_that_is_no_file_name_reads_no_starter(self):
        self.project('[tickets]\nstore = "../repo"\n')
        self.assertEqual(self.read("verbs.write").returncode, 2)

    def test_a_project_file_that_names_no_store_reads_the_repo_starter(self):
        for text in ("[workflow]\nx = 1\n", "[tickets]\nstore = 5\n", '[tickets]\nstore = ""\nkey = "K"\n'):
            self.project(text)
            self.assertEqual(self.read("verbs.write").stdout, "Edit the file.\nCommit it.\n", text)
            self.assertEqual(self.read("tickets.store").stdout, "repo\n", text)
        self.assertEqual(self.read("tickets.key").stdout, "K\n")

    def test_several_keys_with_one_missing_print_the_rest_and_exit_2(self):
        result = self.read("tickets.store", "tickets.nope")
        self.assertEqual(result.returncode, 2)
        self.assertEqual(json.loads(result.stdout), {"tickets.store": "repo"})
        self.assertIn("missing: tickets.nope", result.stderr)

    def test_an_unreadable_project_file_exits_1(self):
        self.project("[tickets\nstore = ")
        result = self.read("tickets.store")
        self.assertEqual(result.returncode, 1)
        self.assertIn("cannot read the store config", result.stderr)

    def test_starters_lists_each_store_with_its_description(self):
        result = self.run_script("--starters")
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(json.loads(result.stdout), {"jira": "Jira through acli.", "repo": "Files in the repo."})

    def test_every_shipped_starter_parses_and_is_named_for_its_store(self):
        shipped = SCRIPT.parents[1] / "config"
        result = subprocess.run(
            [sys.executable, str(SCRIPT), "--starters"], encoding="utf-8", capture_output=True, check=False
        )
        self.assertEqual(result.returncode, 0, result.stderr)
        self.assertEqual(
            sorted(f"{store}-ticketing.toml" for store in json.loads(result.stdout)),
            sorted(p.name for p in shipped.glob("*-ticketing.toml")),
        )


if __name__ == "__main__":
    unittest.main()
scripts/tests/test_tickets.py›
import json
import shutil
import subprocess
import sys
import tempfile
import tomllib
import unittest
from datetime import date
from pathlib import Path

SCRIPT = Path(__file__).resolve().parents[1] / "tickets.py"
CONFIG_UTILS = SCRIPT.parents[2] / "bmad" / "scripts" / "config_utils.py"


def ticket(
    status,
    ticket_id=None,
    after="[]",
    hitl="false",
    tracker_id='""',
    assignee='""',
    blocked_at=None,
    blocked_reason=None,
    kind="story",
    refined="false",
    tracker_status=None,
):
    lines = ["---"]
    if ticket_id is not None:
        lines.append(f"id: {ticket_id}")
    lines += [
        f"tracker_id: {tracker_id}",
        'remote: ""',
        f"type: {kind}",
        'title: "x"',
        "parent: epic-cart",
        "covers: [R1]",
        f"after: {after}",
        f"assignee: {assignee}",
    ]
    if status:
        lines.append(f"status: {status}")
    if tracker_status:
        lines.append(f"tracker_status: {tracker_status}")
    lines += [
        f"refined: {refined}",
        f"hitl: {hitl}",
        "risk: low",
    ]
    if blocked_at:
        lines.append(f'blocked_at: "{blocked_at}"')
    if blocked_reason:
        lines.append(f'blocked_reason: "{blocked_reason}"')
    lines += ["---", "", "# x", ""]
    return "\n".join(lines)


def plan(ticket_id, status=None, assignee=None, blocked_at=None, blocked_reason=None):
    """A plan as the build's template writes it: quoted values, trailing comments, block lists."""
    lines = [
        "---",
        "title: 'x'",
        "type: 'feature' # feature | bugfix | refactor | chore",
        f"ticket: '{ticket_id}' # the entry id from the ticket tree, or the story file's stem when the entry has no id; empty outside it",
    ]
    if status:
        lines.append(f"status: '{status}' # draft | ready-for-dev | in-progress | in-review | built | done")
    for key, value in (("assignee", assignee), ("blocked_at", blocked_at), ("blocked_reason", blocked_reason)):
        if value:
            lines.append(f"{key}: '{value}'")
    lines += ["lenses_ran: []", "deferred:", "  - summary: a", "    evidence: b", "---", "", "# x", ""]
    return "\n".join(lines)


def run(*args, cwd=None, stdin=None):
    # tickets.py writes UTF-8; text=True alone would decode with the locale's code page on Windows.
    return subprocess.run(
        [sys.executable, str(SCRIPT), *args], encoding="utf-8", capture_output=True, check=False, cwd=cwd, input=stdin
    )


class TreeCase(unittest.TestCase):
    """A temp project with `_bmad/custom/` and the epic `out/initiative-checkout/epic-cart`."""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.root = Path(self.tmp.name)
        (self.root / "_bmad" / "custom").mkdir(parents=True)
        self.initiative = self.root / "out" / "initiative-checkout"
        self.epic = self.add_epic("epic-cart")
        self.write_store("repo")

    def tearDown(self):
        self.tmp.cleanup()

    def write_store(self, name="repo", root=None):
        line = f'root = "{root}"\n' if root is not None else ""
        (self.root / "_bmad" / "custom" / "ticketing-store-config.toml").write_text(
            f'[tickets]\nstore = "{name}"\n{line}'
        )

    def add_epic(self, slug, status="", after="[]"):
        folder = self.initiative / slug
        folder.mkdir(parents=True)
        line = f"status: {status}\n" if status else ""
        (folder / f"{slug}.md").write_text(f"---\ntype: epic\n{line}after: {after}\n---\n# {slug}\n")
        return folder

    def add_backlog(self):
        backlog = self.root / "out" / "backlog"
        backlog.mkdir()
        return backlog

    def status_rows(self, folder):
        return json.loads(run("status", str(folder)).stdout)["tickets"]

    def add(self, name, text, folder=None):
        ((folder or self.epic) / name).write_text(text, encoding="utf-8")

    def seed(self, s1="", s2="", s3=""):
        self.add("story-scaffold.md", ticket(s1, 1, hitl="true"))
        self.add("story-ui-shell.md", ticket(s2, 2))
        self.add("story-tracer.md", ticket(s3, 3, after="[1, 2]"))
        self.add("story-codes.md", ticket("", 4, after="[story-tracer]"))
        self.add("spike-tax.md", ticket("", 5, after="[3]", kind="spike"))

    def pricing(self):
        pricing = self.add_epic("epic-pricing")
        (pricing / "tickets.toml").write_text(
            '[[entry]]\nid = 1\ntype = "story"\ntitle = "Pricing contract"\n\n'
            '[[entry]]\nid = 2\ntype = "story"\ntitle = "Pricing rules"\nafter = [1]\n'
        )
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = 1\nslug = "epic-pricing"\n\n[[epic]]\nid = 2\nslug = "epic-cart"\n'
        )
        return pricing


class TicketsTests(TreeCase):
    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_unstarted_tickets_with_no_prerequisites_are_ready_to_refine(self):
        self.seed()
        out = self.next()
        self.assertEqual(self.files(out["ready_to_refine"]), ["story-scaffold.md", "story-ui-shell.md"])
        self.assertEqual(out["ready_to_start"], [])
        self.assertEqual(self.files(out["blocked"]), ["story-tracer.md", "story-codes.md", "spike-tax.md"])
        self.assertTrue(out["ready_to_refine"][0]["hitl"])

    def test_unrefined_ticket_is_never_ready_to_start(self):
        self.seed(s1="done", s2="draft")
        out = self.next()
        self.assertEqual(out["ready_to_start"], [])
        self.assertIn("story-ui-shell.md", self.files(out["ready_to_refine"]))

    def test_refined_is_ready_to_start_and_ready_set_moves_when_prerequisites_done(self):
        self.seed(s1="done")
        self.add("story-ui-shell.md", ticket("ready-for-dev", 2, refined="true"))
        out = self.next()
        self.assertEqual(self.files(out["ready_to_start"]), ["story-ui-shell.md"])
        self.assertIn("story-tracer.md", self.files(out["blocked"]))
        self.seed(s1="done", s2="done")
        out = self.next()
        self.assertEqual(self.files(out["ready_to_refine"]), ["story-tracer.md"])

    def test_prerequisites_resolve_by_id_stem_and_tracker_id(self):
        self.seed(s1="done", s2="done", s3="done")
        self.add("story-by-id.md", ticket("", 6, after="[CART-4]"))
        self.add("story-codes.md", ticket("done", 4, after="[story-tracer]", tracker_id='"CART-4"'))
        out = self.next()
        self.assertIn("story-by-id.md", self.files(out["ready_to_refine"]))
        self.assertIn("spike-tax.md", self.files(out["ready_to_refine"]))

    def test_in_progress_review_blocked_build_and_blocked_at(self):
        self.seed(s1="in-progress", s2="in-review")
        self.add("story-ui-shell.md", ticket("", 2, blocked_at="2026-09-05"))
        self.add("story-codes.md", ticket("blocked", 4))
        out = self.next()
        self.assertEqual(self.files(out["in_progress"]), ["story-scaffold.md"])
        self.assertEqual(
            self.files(out["blocked"]), ["story-ui-shell.md", "story-tracer.md", "story-codes.md", "spike-tax.md"]
        )
        self.assertEqual([r["state"] for r in out["blocked"]], ["backlog", "backlog", "in-progress", "backlog"])

    def test_tracker_status_wins_over_the_build_status_for_state(self):
        self.seed(s1="in-progress", s2="ready-for-dev")
        self.add("story-scaffold.md", ticket("in-progress", 1, tracker_status="done"))
        self.add("story-ui-shell.md", ticket("", 2, tracker_status="in-progress"))
        out = self.next()
        self.assertEqual(self.files(out["in_progress"]), ["story-ui-shell.md"])
        self.assertEqual(out["in_progress"][0]["status"], "")
        self.assertEqual(self.files(out["blocked"]), ["story-tracer.md", "story-codes.md", "spike-tax.md"])
        self.add("story-ui-shell.md", ticket("", 2, tracker_status="done"))
        self.assertEqual(self.files(self.next()["ready_to_refine"]), ["story-tracer.md"])
        self.add("story-ui-shell.md", ticket("", 2, tracker_status="todo"))
        self.assertIn("tracker_status", run("next", str(self.epic)).stderr)

    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["id"] for t in out["tickets"]], [1, 2, 3, 4, 5])
        self.assertEqual(out["counts"], {"total": 5, "done": 1, "backlog": 4})
        self.assertEqual(out["longest_remaining_chain"], ["epic-cart/2", "epic-cart/3", "epic-cart/4"])
        self.assertEqual(out["tickets"][2]["blocks"], [4, 5])

    BREAKDOWN = """
[[entry]]
id = 1
type = "story"
title = "Scaffold"
covers = ["R1"]

[[entry]]
id = 2
type = "story"
title = "UI shell"
after = [1]
covers = ["R1"]

[[entry]]
id = 3
type = "spike"
title = "Tax engine?"
after = [1]
covers = ["R4"]
hitl = true

[[entry]]
id = 4
type = "story"
title = "Codes"
after = [2, 3]
covers = ["R2", "R3"]
"""

    def breakdown_epic(self, text=None):
        (self.epic / "tickets.toml").write_text(text or self.BREAKDOWN, encoding="utf-8")

    def test_text_outside_ascii_is_read_and_written_as_utf8(self):
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Scaffold"', 'title = "Café ✓ menu"'))
        r = subprocess.run([sys.executable, str(SCRIPT), "next", str(self.epic)], capture_output=True, check=False)
        self.assertEqual(r.returncode, 0, r.stderr)
        out = json.loads(r.stdout.decode("utf-8"))
        self.assertEqual(out["ready_to_start"][0]["title"], "Café ✓ menu")
        r = subprocess.run([sys.executable, str(SCRIPT), "pull", str(self.epic), "1"], capture_output=True, check=False)
        self.assertEqual(r.returncode, 0, r.stderr)
        self.assertIn("# Café ✓ menu", (self.epic / "story-cafe-menu.md").read_text(encoding="utf-8"))

    def test_titles_that_slug_alike_get_their_own_file_and_plan(self):
        self.breakdown_epic(
            '[[entry]]\nid = 1\ntype = "story"\ntitle = "Café menu"\n\n'
            '[[entry]]\nid = 2\ntype = "story"\ntitle = "Cafe menu"\n'
        )
        plans = [Path(json.loads(run("find", str(self.epic), n).stdout)["plan"]).name for n in ("1", "2")]
        self.assertEqual(plans, ["story-cafe-menu-plan.md", "story-cafe-menu-2-plan.md"])
        self.assertEqual(json.loads(run("pull", str(self.epic), "2").stdout)["file"], "story-cafe-menu-2.md")
        self.assertEqual(json.loads(run("pull", str(self.epic), "1").stdout)["file"], "story-cafe-menu.md")
        plans = [Path(json.loads(run("find", str(self.epic), n).stdout)["plan"]).name for n in ("1", "2")]
        self.assertEqual(plans, ["story-cafe-menu-plan.md", "story-cafe-menu-2-plan.md"])

    def test_a_leaf_file_with_a_byte_order_mark_is_read(self):
        (self.epic / "story-scaffold.md").write_bytes(b"\xef\xbb\xbf" + ticket("done", 1).encode("utf-8"))
        rows = self.status_rows(self.epic)
        self.assertEqual([(r["file"], r["state"]) for r in rows], [("story-scaffold.md", "done")])

    def test_mark_keeps_a_plans_crlf_line_endings_and_byte_order_mark(self):
        self.breakdown_epic()
        path = self.epic / "story-scaffold-plan.md"
        text = plan(1, "in-progress").replace("\n", "\r\n")
        path.write_bytes(b"\xef\xbb\xbf" + text.encode("utf-8"))
        self.mark(str(self.epic), "1", "done")
        data = path.read_bytes()
        self.assertTrue(data.startswith(b"\xef\xbb\xbf"))
        self.assertNotIn(b"\n", data.replace(b"\r\n", b""))
        self.assertIn(b"\r\nstatus: done\r\n", data)
        self.assertEqual(self.status_rows(self.epic)[0]["status"], "done")

    def test_planned_entries_surface_when_unblocked(self):
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("done", 1))
        out = self.next()
        self.assertEqual(
            [(e["id"], e["type"], e["title"]) for e in out["ready_to_start"]],
            [(2, "story", "UI shell"), (3, "spike", "Tax engine?")],
        )
        self.assertEqual(out["ready_to_start"][1]["covers"], ["R4"])
        self.assertEqual(out["ready_to_start"][1]["after"], [1])
        self.assertTrue(out["ready_to_start"][1]["hitl"])
        self.assertEqual([e["id"] for e in out["blocked"]], [4])
        self.add("story-ui-shell.md", ticket("", 2, after="[1]"))
        out = self.next()
        self.assertEqual(self.files(out["ready_to_start"]), ["story-ui-shell.md", None])
        status = json.loads(run("status", str(self.epic)).stdout)
        self.assertEqual(status["counts"], {"total": 4, "done": 1, "backlog": 1, "planned": 2})
        self.assertEqual(status["tickets"][0]["blocks"], [2, 3])

    def test_table_order_is_build_order_not_id(self):
        self.breakdown_epic(
            '[[entry]]\nid = 2\ntype = "story"\ntitle = "Second first"\n\n'
            '[[entry]]\nid = 1\ntype = "story"\ntitle = "First second"\n\n'
            '[[entry]]\nid = 3\ntype = "story"\ntitle = "Third"\nafter = [1, 2]\n'
        )
        self.assertEqual([e["id"] for e in self.next()["ready_to_start"]], [2, 1])
        self.add("story-first-second.md", ticket("draft", 1))
        rows = self.status_rows(self.epic)
        self.assertEqual([r["id"] for r in rows], [2, 1, 3])

    def test_entry_waiting_on_unwritten_entry_stays_blocked(self):
        self.breakdown_epic()
        out = self.next()
        self.assertEqual([e["id"] for e in out["ready_to_start"]], [1])
        self.assertEqual([e["id"] for e in out["blocked"]], [2, 3, 4])

    def test_a_prerequisite_in_review_is_met_and_one_in_progress_is_not(self):
        self.breakdown_epic()
        self.add("story-scaffold-plan.md", plan(1, "in-review"))
        out = self.next()
        self.assertEqual([e["id"] for e in out["ready_to_start"]], [2, 3])
        self.assertEqual([e["id"] for e in out["in_progress"]], [1])
        self.add("story-scaffold-plan.md", plan(1, "in-progress"))
        out = self.next()
        self.assertEqual(out["ready_to_start"], [])
        self.assertEqual([e["id"] for e in out["blocked"]], [2, 3, 4])

    def test_an_epic_gate_waits_for_the_epic_to_be_done_even_with_its_tickets_in_review(self):
        pricing = self.pricing()
        self.breakdown_epic('[[entry]]\nid = 1\ntype = "story"\ntitle = "Cart"\nafter = ["1.2"]\n')
        self.add("story-pricing-contract-plan.md", plan(1, "in-review"), pricing)
        self.add("story-pricing-rules-plan.md", plan(2, "in-review"), pricing)
        self.assertEqual([e["id"] for e in self.next()["ready_to_start"]], [1])
        (self.epic / "epic-cart.md").write_text("---\ntype: epic\nafter: [epic-pricing]\n---\n# epic-cart\n")
        out = self.next()
        self.assertEqual(out["ready_to_start"], [])
        self.assertEqual(out["blocked"][0]["gated_by"], ["epic-pricing"])
        (pricing / "epic-pricing.md").write_text("---\ntype: epic\nstatus: done\n---\n# epic-pricing\n")
        self.assertEqual([e["id"] for e in self.next()["ready_to_start"]], [1])

    def test_every_row_carries_a_ref_that_find_resolves_to_the_same_row(self):
        self.pricing()
        self.breakdown_epic()
        backlog = self.add_backlog()
        self.add("bug-x.md", ticket("draft", kind="bug"), backlog)
        cases = (
            (self.initiative, ["1.1", "1.2", "2.1", "2.2", "2.3", "2.4"]),
            (self.epic, ["2.1", "2.2", "2.3", "2.4"]),
            (backlog, ["bug-x.md"]),
        )
        for folder, refs in cases:
            rows = self.status_rows(folder)
            self.assertEqual([r["ref"] for r in rows], refs, folder.name)
            for row in rows:
                r = run("find", str(folder), row["ref"])
                self.assertEqual(r.returncode, 0, r.stderr)
                found = json.loads(r.stdout)
                self.assertEqual(
                    [found[k] for k in ("epic", "id", "file", "ref")], [row[k] for k in ("epic", "id", "file", "ref")]
                )
            for group in ("ready_to_start", "blocked"):
                for row in json.loads(run("next", str(folder)).stdout)[group]:
                    self.assertIn(row["ref"], refs)

    def test_a_ref_is_the_bare_id_in_an_epic_the_initiative_does_not_number(self):
        self.breakdown_epic()
        self.assertEqual([r["ref"] for r in self.next()["ready_to_start"]], ["1"])
        self.assertEqual(json.loads(run("find", str(self.epic), "1").stdout)["title"], "Scaffold")

    def test_file_prerequisites_win_over_the_entry_and_status_flags_the_drift(self):
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("draft", 1))
        self.add("story-ui-shell.md", ticket("draft", 2, after="[]"))
        self.assertIn("story-ui-shell.md", self.files(self.next()["ready_to_start"]))
        rows = self.status_rows(self.epic)
        self.assertTrue(rows[1]["drift"])
        self.assertNotIn("drift", rows[0])

    def test_a_leaf_file_without_after_or_hitl_reads_them_as_empty_and_flags_the_drift(self):
        self.breakdown_epic()
        self.add("story-ui-shell.md", '---\nid: 2\ntype: story\ntitle: "UI shell"\n---\n# UI shell\n')
        self.add("spike-tax-engine.md", '---\nid: 3\ntype: spike\ntitle: "Tax engine?"\nafter: [1]\n---\n# Tax\n')
        rows = {r["id"]: r for r in self.status_rows(self.epic)}
        self.assertEqual((rows[2]["after"], rows[2]["drift"]), ([], {"after": {"file": [], "entry": [1]}}))
        self.assertEqual((rows[3]["hitl"], rows[3]["drift"]), (False, {"hitl": {"file": False, "entry": True}}))
        self.assertNotIn("drift", rows[1])

    def test_a_blocked_row_names_the_prerequisites_it_waits_on(self):
        self.pricing()
        self.breakdown_epic(self.BREAKDOWN.replace("after = [2, 3]", 'after = [2, 3, "1.2"]'))
        self.add("story-scaffold-plan.md", plan(1, "in-progress"))
        self.add("spike-tax-engine-plan.md", plan(3, "in-review"))
        self.add("story-ui-shell-plan.md", plan(2, "blocked", blocked_reason="legal"))
        out = self.next()
        blocked = {e["id"]: e for e in out["blocked"]}
        self.assertEqual(blocked[4]["waiting_on"], [2, "1.2"])
        self.assertEqual(blocked[2]["waiting_on"], [1])
        # 3 is in review with 1 still open: a ticket already under way is not waiting.
        self.assertEqual([e["id"] for e in out["in_progress"]], [1, 3])
        self.assertFalse(any("waiting_on" in e for e in out["in_progress"]))
        self.add("story-scaffold-plan.md", plan(1, "done"))
        self.assertNotIn("waiting_on", {e["id"]: e for e in self.next()["blocked"]}[2])

    def test_status_gives_the_id_a_new_ticket_takes(self):
        self.pricing()
        self.breakdown_epic()
        self.assertEqual(json.loads(run("status", str(self.epic)).stdout)["next_id"], 5)
        self.breakdown_epic(self.BREAKDOWN + '\n[[entry]]\nid = "6a"\ntype = "story"\ntitle = "Lettered"\n')
        self.assertEqual(json.loads(run("status", str(self.epic)).stdout)["next_id"], 7)
        self.add("story-unlisted.md", ticket("", 9))
        out = json.loads(run("status", str(self.epic)).stdout)
        self.assertEqual(out["next_id"], 10)
        out = json.loads(run("status", str(self.initiative)).stdout)
        self.assertNotIn("next_id", out)
        self.assertEqual({e["slug"]: e["next_id"] for e in out["epics"]}, {"epic-pricing": 3, "epic-cart": 10})
        backlog = self.add_backlog()
        self.assertEqual(json.loads(run("status", str(backlog)).stdout)["next_id"], 1)
        self.add("bug-x.md", ticket("draft", 7, kind="bug"), backlog)
        self.assertEqual(json.loads(run("status", str(backlog)).stdout)["next_id"], 8)

    def test_a_folders_own_next_id_keeps_a_moved_entrys_id_from_coming_back(self):
        self.breakdown_epic("next_id = 9\n" + self.BREAKDOWN)
        self.assertEqual(json.loads(run("status", str(self.epic)).stdout)["next_id"], 9)
        self.breakdown_epic("next_id = 3\n" + self.BREAKDOWN)
        self.assertEqual(json.loads(run("status", str(self.epic)).stdout)["next_id"], 5)
        self.breakdown_epic('next_id = "9"\n' + self.BREAKDOWN)
        r = run("status", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("`next_id` must be a whole number", r.stderr)

    def test_next_and_find_carry_the_drift_too(self):
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("draft", 1))
        self.add("story-ui-shell.md", ticket("draft", 2, after="[]"))
        drift = {"after": {"file": [], "entry": [1]}}
        ready = {r["id"]: r for r in self.next()["ready_to_start"]}
        self.assertEqual(ready[2]["drift"], drift)
        self.assertNotIn("drift", ready[1])
        self.assertEqual(json.loads(run("find", str(self.epic), "2").stdout)["drift"], drift)

    def test_an_unknown_holds_a_ticket_until_it_is_settled(self):
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Scaffold"', 'title = "Scaffold"\nunknown = "Which host?"'))
        out = self.next()
        self.assertEqual(out["ready_to_start"], [])
        row = {e["id"]: e for e in out["blocked"]}[1]
        self.assertEqual(row["unknown"], "Which host?")
        self.assertNotIn("waiting_on", row)
        # Pulled, the file's Notes hold it.
        self.assertEqual(run("pull", str(self.epic), "1").returncode, 0)
        self.assertEqual({e["id"]: e for e in self.next()["blocked"]}[1]["unknown"], "Which host?")
        leaf = self.epic / "story-scaffold.md"
        text = leaf.read_text(encoding="utf-8")
        leaf.write_text(text.replace("- Unknown: Which host?", "- Decision: the EU host."), encoding="utf-8")
        out = self.next()
        self.assertEqual([e["id"] for e in out["ready_to_start"]], [1])
        self.assertNotIn("unknown", out["ready_to_start"][0])

    def test_an_unknown_does_not_keep_a_ticket_from_refining(self):
        self.breakdown_epic(
            self.BREAKDOWN.replace('title = "Scaffold"', 'title = "Scaffold"\nrefine = true\nunknown = "Which host?"')
        )
        self.assertEqual([e["id"] for e in self.next()["ready_to_refine"]], [1])
        # Refined, and the only `Unknown:` left is a template's example in a comment.
        self.add("story-scaffold.md", ticket("", 1, refined="true") + "<!-- Example:\n- Unknown: not this one\n-->\n")
        self.assertEqual([e["id"] for e in self.next()["ready_to_start"]], [1])

    def test_rows_carry_the_checkpoints_an_entry_sets(self):
        self.breakdown_epic(
            self.BREAKDOWN.replace(
                'title = "Scaffold"', 'title = "Scaffold"\nplan_checkpoint = true\ndone_checkpoint = true'
            )
        )
        first, second = self.next()["ready_to_start"][0], self.status_rows(self.epic)[1]
        self.assertEqual((first["plan_checkpoint"], first["done_checkpoint"]), (True, True))
        self.assertNotIn("plan_checkpoint", second)
        self.assertTrue(json.loads(run("find", str(self.epic), "1").stdout)["done_checkpoint"])

    def test_every_row_carries_the_tickets_risk_and_the_leaf_files_wins(self):
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Scaffold"', 'title = "Scaffold"\nrisk = "medium"'))
        rows = {r["id"]: r for r in self.status_rows(self.epic)}
        self.assertEqual((rows[1]["risk"], rows[2]["risk"]), ("medium", ""))
        self.assertEqual(self.next()["ready_to_start"][0]["risk"], "medium")
        self.add("story-scaffold.md", ticket("", 1).replace("risk: low", "risk: high"))
        self.assertEqual(json.loads(run("find", str(self.epic), "1").stdout)["risk"], "high")

    def test_a_plan_without_an_assignee_keeps_the_leafs_and_one_with_it_wins(self):
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("", 1, assignee='"ann"'))
        self.add("story-scaffold-plan.md", plan(1, "in-progress"))
        self.assertEqual(self.status_rows(self.epic)[0]["assignee"], "ann")
        self.add("story-scaffold-plan.md", plan(1, "in-progress", assignee="bob"))
        self.assertEqual(self.status_rows(self.epic)[0]["assignee"], "bob")

    def mirror(self, items, folder=None):
        return run("mirror", str(folder or self.epic), stdin=json.dumps(items))

    def test_mirror_refuses_the_repo_store(self):
        self.breakdown_epic()
        r = self.mirror([{"ref": "1", "tracker_status": "done"}])
        self.assertEqual(r.returncode, 2)
        self.assertIn("no tracker", r.stderr)
        self.assertEqual(list(self.epic.glob("story-*.md")), [])

    def test_mirror_pulls_a_leaf_and_writes_only_the_keys_given(self):
        self.write_store("jira")
        self.breakdown_epic()
        r = self.mirror(
            [
                {
                    "ref": 2,
                    "tracker_id": "42",
                    "remote": "https://x.test/42",
                    "tracker_status": "in-progress",
                    "assignee": "ann",
                    "after": [1, "41"],
                },
                {"ref": "scaffold", "tracker_id": "41", "tracker_status": "done"},
            ]
        )
        self.assertEqual(r.returncode, 0, r.stderr)
        out = json.loads(r.stdout)
        self.assertEqual(
            [(m["ref"], m["file"], m["pulled"]) for m in out["mirrored"]],
            [("2", "story-ui-shell.md", True), ("1", "story-scaffold.md", True)],
        )
        self.assertEqual(out["unmatched"], [])
        text = (self.epic / "story-ui-shell.md").read_text(encoding="utf-8")
        for line in (
            'tracker_id: "42"',
            'remote: "https://x.test/42"',
            "tracker_status: in-progress",
            'assignee: "ann"',
            'after: [1, "41"]',
        ):
            self.assertIn(f"\n{line}\n", text)
        self.assertNotIn("\nstatus:", text)
        rows = {t["id"]: t for t in self.status_rows(self.epic)}
        self.assertEqual((rows[2]["state"], rows[2]["assignee"], rows[2]["after"]), ("in-progress", "ann", [1]))
        self.assertEqual(rows[1]["state"], "done")
        self.assertNotIn("drift", rows[2])
        self.assertEqual([e["id"] for e in self.next("--synced")["ready_to_start"]], [3])

    def test_mirror_finds_a_ticket_by_its_tracker_id_and_an_empty_value_removes_the_line(self):
        self.write_store("github")
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("", 1, tracker_id='"41"', assignee='"ann"', tracker_status="backlog"))
        r = self.mirror([{"tracker_id": "41", "tracker_status": "review", "assignee": ""}])
        self.assertEqual(r.returncode, 0, r.stderr)
        self.assertFalse(json.loads(r.stdout)["mirrored"][0]["pulled"])
        text = (self.epic / "story-scaffold.md").read_text(encoding="utf-8")
        self.assertIn("\ntracker_status: review\n", text)
        self.assertNotIn("assignee", text)
        self.assertIn('\ntracker_id: "41"\n', text)

    def test_a_tracker_id_alone_matches_only_the_ticket_that_carries_it(self):
        self.write_store("github")
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Codes"', 'title = "Port SHOP-12 codes"'))
        self.add("story-scaffold.md", ticket("", 1, tracker_id='"3"'))
        r = self.mirror([{"tracker_id": 3, "tracker_status": "done"}, {"tracker_id": "SHOP-12", "assignee": "ann"}])
        self.assertEqual(r.returncode, 0, r.stderr)
        out = json.loads(r.stdout)
        self.assertEqual([m["file"] for m in out["mirrored"]], ["story-scaffold.md"])
        self.assertEqual([u["ref"] for u in out["unmatched"]], ["SHOP-12"])
        self.assertEqual([p.name for p in self.epic.glob("s*.md")], ["story-scaffold.md"])
        self.assertEqual(self.status_rows(self.epic)[0]["state"], "done")
        self.add("story-ui-shell.md", ticket("", 2, tracker_id='"3"'))
        r = self.mirror([{"tracker_id": "3", "tracker_status": "backlog"}])
        self.assertEqual(r.returncode, 1)
        self.assertIn("more than one", json.loads(r.stderr)["error"])

    def test_mirror_lists_a_ticket_the_tree_does_not_hold_and_writes_the_rest(self):
        self.write_store("linear")
        self.breakdown_epic()
        r = self.mirror([{"ref": "SHOP-99", "tracker_status": "done"}, {"ref": 1, "tracker_status": "done"}])
        self.assertEqual(r.returncode, 0, r.stderr)
        out = json.loads(r.stdout)
        self.assertEqual([u["ref"] for u in out["unmatched"]], ["SHOP-99"])
        self.assertEqual(self.status_rows(self.epic)[0]["state"], "done")

    def test_a_failed_mirror_still_lists_the_unmatched_tickets(self):
        self.write_store("linear")
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("", 1, tracker_id='"SHOP-1"'))
        before = (self.epic / "story-scaffold.md").read_bytes()
        # A known ticket now waits on a tracker item the tree does not hold yet.
        r = self.mirror(
            [{"tracker_id": "SHOP-1", "after": ["SHOP-2"]}, {"tracker_id": "SHOP-2", "tracker_status": "backlog"}]
        )
        self.assertEqual(r.returncode, 1)
        err = json.loads(r.stderr)
        self.assertIn("nothing was mirrored", err["error"])
        self.assertEqual([u["ref"] for u in err["unmatched"]], ["SHOP-2"])
        self.assertEqual((self.epic / "story-scaffold.md").read_bytes(), before)

    def test_a_tracker_id_on_two_tickets_is_refused_and_a_mirror_that_makes_one_rolls_back(self):
        self.write_store("linear")
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("", 1, tracker_id='"SHOP-1"'))
        before = (self.epic / "story-scaffold.md").read_bytes()
        # The same id again, in another case, would make `after = ["SHOP-1"]` point at whichever was read last.
        r = self.mirror([{"ref": 2, "tracker_id": "shop-1"}])
        self.assertEqual(r.returncode, 1)
        self.assertIn("more than one ticket", json.loads(r.stderr)["error"])
        self.assertEqual((self.epic / "story-scaffold.md").read_bytes(), before)
        self.assertFalse((self.epic / "story-ui-shell.md").exists())
        self.add("story-ui-shell.md", ticket("", 2, after="[1]", tracker_id='"SHOP-1"'))
        r = run("status", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("tracker_id 'shop-1' is on more than one ticket", json.loads(r.stderr)["error"])

    def test_mirror_writes_nothing_when_a_value_is_refused_or_breaks_the_tree(self):
        self.write_store("jira")
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("", 1))
        before = (self.epic / "story-scaffold.md").read_text(encoding="utf-8")
        for items, message in (
            ([{"ref": 1, "tracker_status": "done"}, {"ref": 2, "status": "done"}], "never mirrored"),
            ([{"ref": 1, "tracker_status": "closed"}], "tracker_status"),
            ([{"ref": 1, "tracker_status": "done"}, {"ref": 2, "after": ["NOPE-7"]}], "nothing was mirrored"),
            ([{"ref": 1, "tracker_status": "done"}, {"ref": "1", "assignee": "ann"}], "named twice"),
            ({"ref": 1}, "JSON array"),
        ):
            r = self.mirror(items)
            self.assertEqual(r.returncode, 1, items)
            self.assertIn(message, json.loads(r.stderr)["error"])
            self.assertEqual((self.epic / "story-scaffold.md").read_text(encoding="utf-8"), before)
            self.assertFalse((self.epic / "story-ui-shell.md").exists())
        r = run("mirror", str(self.epic), stdin="not json")
        self.assertEqual(r.returncode, 1)

    def test_mirror_keeps_a_leafs_crlf_line_endings(self):
        self.write_store("jira")
        self.breakdown_epic()
        (self.epic / "story-scaffold.md").write_bytes(ticket("", 1).replace("\n", "\r\n").encode("utf-8"))
        self.assertEqual(self.mirror([{"ref": 1, "tracker_status": "done"}]).returncode, 0)
        raw = (self.epic / "story-scaffold.md").read_bytes()
        self.assertIn(b"\r\ntracker_status: done\r\n", raw)
        self.assertNotIn(b"\n", raw.replace(b"\r\n", b""))

    def test_a_pulled_leaf_matches_its_entry_until_the_entry_changes(self):
        self.breakdown_epic()
        for n in ("2", "3"):
            self.assertEqual(run("pull", str(self.epic), n).returncode, 0)
        self.assertFalse(any("drift" in r for r in self.status_rows(self.epic)))
        self.breakdown_epic(self.BREAKDOWN.replace("hitl = true", "hitl = false"))
        rows = {r["id"]: r for r in self.status_rows(self.epic)}
        self.assertTrue(rows[3]["drift"])
        self.assertTrue(rows[3]["hitl"])

    def test_pull_writes_the_leaf_and_only_a_refine_entry_waits_for_refinement(self):
        self.breakdown_epic(
            self.BREAKDOWN.replace(
                'title = "Scaffold"', 'title = "Scaffold: the cart!"\nverify = "It runs."\nrefine = true'
            )
        )
        r = run("pull", str(self.epic), "1")
        self.assertEqual(r.returncode, 0, r.stderr)
        self.assertEqual(json.loads(r.stdout), {"file": "story-scaffold-the-cart.md", "refine": True})
        text = (self.epic / "story-scaffold-the-cart.md").read_text(encoding="utf-8")
        self.assertIn("Verify: It runs.", text)
        self.assertIn("covers: [R1]", text)
        self.assertEqual(self.files(self.next()["ready_to_refine"]), ["story-scaffold-the-cart.md"])
        self.assertEqual(run("pull", str(self.epic), "1").returncode, 1)
        run("mark", str(self.epic), "1", "done")
        run("pull", str(self.epic), "2")
        out = self.next()
        self.assertEqual(self.files(out["ready_to_start"]), ["story-ui-shell.md", None])
        self.assertEqual(out["ready_to_start"][0]["after"], [1])

    def test_a_bug_waits_for_refinement_without_refine_set(self):
        self.breakdown_epic('[[entry]]\nid = 1\ntype = "bug"\ntitle = "Missing criteria"\n')
        out = self.next()
        self.assertEqual([t["id"] for t in out["ready_to_refine"]], [1])
        self.assertEqual(out["ready_to_start"], [])
        r = run("pull", str(self.epic), "1")
        self.assertEqual(r.returncode, 0, r.stderr)
        self.assertEqual(json.loads(r.stdout), {"file": "bug-missing-criteria.md", "refine": True})
        self.assertEqual(self.files(self.next()["ready_to_refine"]), ["bug-missing-criteria.md"])

    def test_a_leaf_file_that_says_bug_waits_for_refinement(self):
        self.breakdown_epic('[[entry]]\nid = 1\ntype = "story"\ntitle = "Was a story"\n')
        self.add("bug-was-a-story.md", ticket("ready-for-dev", 1, kind="bug"))
        self.assertEqual(self.files(self.next()["ready_to_refine"]), ["bug-was-a-story.md"])

    def test_a_refined_bug_is_ready_to_start(self):
        self.breakdown_epic('[[entry]]\nid = 1\ntype = "bug"\ntitle = "Refined bug"\n')
        self.add("bug-refined-bug.md", ticket("ready-for-dev", 1, kind="bug", refined="true"))
        out = self.next()
        self.assertEqual(self.files(out["ready_to_start"]), ["bug-refined-bug.md"])
        self.assertEqual(out["ready_to_refine"], [])

    def test_pull_leaves_out_empty_fields_but_always_writes_after_and_hitl(self):
        self.breakdown_epic()
        run("pull", str(self.epic), "1")
        head = (self.epic / "story-scaffold.md").read_text(encoding="utf-8").split("---")[1]
        self.assertEqual(
            head, '\nid: 1\ntype: story\ntitle: "Scaffold"\nparent: epic-cart\ncovers: [R1]\nafter: []\nhitl: false\n'
        )
        self.assertEqual(self.next()["ready_to_start"][0]["state"], "backlog")
        run("pull", str(self.epic), "3")
        head = (self.epic / "spike-tax-engine.md").read_text(encoding="utf-8").split("---")[1]
        self.assertIn("\nafter: [1]\n", head)
        self.assertIn("\nhitl: true\n", head)
        self.assertNotIn("refined", head)

    def test_pull_ends_after_the_references(self):
        self.breakdown_epic()
        run("pull", str(self.epic), "1")
        text = (self.epic / "story-scaffold.md").read_text(encoding="utf-8")
        self.assertTrue(
            text.endswith("## References\n\n- parent — out/initiative-checkout/epic-cart/epic-cart.md\n"), text
        )

    def test_find_by_cross_ref_id_file_tracker_id_and_title(self):
        pricing = self.pricing()
        self.breakdown_epic()
        self.add("story-contract.md", ticket("done", 1, tracker_id='"PRICE-1"'), pricing)
        run("pull", str(self.epic), "1")

        def find(folder, ref):
            r = run("find", str(folder), ref)
            self.assertEqual(r.returncode, 0, r.stderr)
            return json.loads(r.stdout)

        hit = find(self.initiative, "2.1")
        self.assertEqual((hit["folder"], hit["id"], hit["file"]), ("epic-cart", 1, "story-scaffold.md"))
        self.assertEqual(hit["story_file"], str((self.epic / "story-scaffold.md").resolve()))
        self.assertNotIn("path", hit)
        self.assertEqual(find(self.epic, "3")["title"], "Tax engine?")
        self.assertIsNone(find(self.epic, "3")["story_file"])
        self.assertEqual(find(self.epic, "1.1")["file"], "story-contract.md")
        self.assertEqual(find(self.epic, "price-1")["file"], "story-contract.md")
        self.assertEqual(find(self.initiative, "story-scaffold")["folder"], "epic-cart")
        self.assertEqual(find(self.initiative, "ui shell")["id"], 2)
        self.assertEqual(find(self.epic, "codes")["id"], 4)
        r = run("find", str(self.initiative), "in")
        self.assertEqual(r.returncode, 1)
        self.assertIn("more than one", r.stderr)
        r = run("find", str(self.initiative), "9.1")
        self.assertEqual(r.returncode, 1)
        self.assertIn("no ticket matches", r.stderr)

    def test_pull_refuses_a_file_name_already_taken(self):
        self.breakdown_epic()
        self.add("story-ui-shell.md", "notes, not a ticket\n")
        r = run("pull", str(self.epic), "2")
        self.assertEqual(r.returncode, 1)
        self.assertIn("exists already", r.stderr)

    def test_pull_writes_references_and_notes(self):
        self.breakdown_epic(
            self.BREAKDOWN.replace(
                'title = "Scaffold"',
                'title = "Scaffold"\nunknown = "Which host?"\nreferences = ["SPINE.md#ad-8"]\nnotes = ["Reuse the mailer."]',
            )
        )
        self.assertEqual(run("pull", str(self.epic), "1").returncode, 0)
        text = (self.epic / "story-scaffold.md").read_text(encoding="utf-8")
        parent = (self.epic / "epic-cart.md").resolve().relative_to(self.root.resolve()).as_posix()
        self.assertIn(f"## References\n\n- parent — {parent}\n- SPINE.md#ad-8\n", text)
        self.assertIn("## Notes\n\n- Unknown: Which host?\n- Reuse the mailer.\n", text)

    def test_a_quoted_title_survives_the_pull(self):
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Scaffold"', "title = 'Say \"hi\"'"))
        self.assertEqual(run("pull", str(self.epic), "1").returncode, 0)
        self.assertEqual(self.next()["ready_to_start"][0]["title"], 'Say "hi"')

    def test_references_must_be_a_list(self):
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Scaffold"', 'title = "Scaffold"\nreferences = "SPINE.md"'))
        r = run("status", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("`references` must be a list", r.stderr)

    def test_dropped_prerequisite_still_blocks(self):
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("dropped", 1))
        self.add("story-ui-shell.md", ticket("", 2, after="[1]"))
        out = self.next()
        self.assertEqual(self.files(out["blocked"])[0], "story-ui-shell.md")
        self.assertEqual(out["ready_to_refine"], [])
        self.assertEqual(out["ready_to_start"], [])

    def test_malformed_breakdown_errors(self):
        for text, message in (
            (self.BREAKDOWN.replace("id = 3", "id = 2"), "two entries with id 2"),
            (self.BREAKDOWN.replace("after = [2, 3]", "after = [2, 9]"), "names no entry in epic-cart"),
            (self.BREAKDOWN.replace('type = "spike"', 'type = "task"'), "is not one of"),
            (self.BREAKDOWN.replace("id = 4", 'id = "3"'), "two entries with id 3"),
            (self.BREAKDOWN.replace("id = 4", 'id = "4-b"'), "needs an `id`"),
            (self.BREAKDOWN.replace("id = 4", 'id = "true"'), "needs an `id`"),
            (self.BREAKDOWN.replace("id = 4\n", ""), "needs an `id`"),
            ("[[entry]\nid = ", "tickets.toml"),
            ('[entry]\nid = 1\ntype = "story"\n', "[[entry]]"),
            (self.BREAKDOWN.replace('covers = ["R4"]', "covers = 1"), "must be a list"),
        ):
            self.breakdown_epic(text)
            r = run("next", str(self.epic))
            self.assertEqual(r.returncode, 1, text)
            self.assertIn(message, json.loads(r.stderr)["error"])

    def test_ticket_waits_on_an_entry_in_another_epic(self):
        pricing = self.pricing()
        self.breakdown_epic(
            self.BREAKDOWN.replace('title = "UI shell"\nafter = [1]', 'title = "UI shell"\nafter = [1, "1.1"]')
        )
        self.add("story-scaffold.md", ticket("done", 1))
        out = self.next()
        self.assertEqual([e["id"] for e in out["ready_to_start"]], [3])
        self.assertEqual(out["blocked"][0]["after"], [1, "1.1"])
        self.add("story-contract.md", ticket("done", 1), pricing)
        self.assertEqual([e["id"] for e in self.next()["ready_to_start"]], [2, 3])

    def test_whole_epic_prerequisite_and_epic_gate(self):
        pricing = self.pricing()
        self.add("story-scaffold.md", ticket("draft", 1, after="[epic-pricing]"))
        self.assertEqual(self.files(self.next()["blocked"]), ["story-scaffold.md"])
        (pricing / "epic-pricing.md").write_text("---\ntype: epic\nstatus: done\n---\n")
        self.assertEqual(self.files(self.next()["ready_to_refine"]), ["story-scaffold.md"])
        gated = self.add_epic("epic-tax", after="[epic-cart]")
        self.add("story-rates.md", ticket("draft", 1), gated)
        out = json.loads(run("next", str(gated)).stdout)
        self.assertEqual(out["blocked"][0]["after"], [])
        self.assertEqual(out["blocked"][0]["gated_by"], ["epic-cart"])

    def test_gate_deadlock_with_a_ticketless_epic_is_a_cycle(self):
        self.add_epic("epic-tax", after="[epic-cart]")
        self.add("story-scaffold.md", ticket("draft", 1, after="[epic-tax]"))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("cycle", r.stderr)

    def test_tracker_ids_resolve_across_epics_and_duplicates_collapse(self):
        pricing = self.pricing()
        self.add("story-contract.md", ticket("done", 1, tracker_id='"PRICE-1"'), pricing)
        self.add("story-scaffold.md", ticket("draft", 1, after="[PRICE-1, 1.1, 1.01]"))
        out = self.next()
        self.assertEqual(out["ready_to_refine"][0]["after"], ["1.1"])

    def test_malformed_files_name_their_folder(self):
        pricing = self.pricing()
        self.seed()
        cases = (
            (ticket("todo", 1), "epic-pricing/story-contract.md"),
            (ticket("draft", 1).replace("after: []", "after:\n  - 1"), "must be inline"),
            (ticket("draft", 1).replace("---\n\n# x", "\n# x"), "does not close"),
        )
        for text, message in cases:
            self.add("story-contract.md", text, pricing)
            self.assertIn(message, json.loads(run("next", str(self.epic)).stderr)["error"])
        self.add("story-contract.md", ticket("draft", 1), pricing)
        (pricing / "epic-pricing.md").write_text("---\ntype: epic\nstatus: Finished\n---\n")
        self.assertIn("epic-pricing/epic-pricing.md", json.loads(run("next", str(self.epic)).stderr)["error"])

    def test_cross_epic_unknown_target_and_cycle_error(self):
        pricing = self.pricing()
        for ref, message in (("9.1", "names no epic id"), ("1.9", "names no entry"), ("epic-missing", "names no epic")):
            self.add("story-scaffold.md", ticket("draft", 1, after=f"[{ref}]"))
            r = run("next", str(self.epic))
            self.assertEqual(r.returncode, 1)
            self.assertIn(message, json.loads(r.stderr)["error"])
        self.add("story-scaffold.md", ticket("draft", 1, after="[1.2]"))
        self.add("story-contract.md", ticket("draft", 1, after="[2.1]"), pricing)
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("cycle", r.stderr)

    def test_initiative_view_spans_epics_in_file_order_and_reports_unpinned_after(self):
        self.pricing()
        self.breakdown_epic()
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = 1\nslug = "epic-pricing"\n\n'
            '[[epic]]\nid = 2\nslug = "epic-cart"\nafter = [{ epic = 1, needs = "the pricing contract" }]\n'
        )
        out = json.loads(run("next", str(self.initiative)).stdout)
        self.assertEqual([(e["epic"], e["id"]) for e in out["ready_to_start"]], [("epic-pricing", 1), ("epic-cart", 1)])
        self.assertEqual(
            out["unpinned_after"], [{"epic": "epic-cart", "after": "epic-pricing", "needs": "the pricing contract"}]
        )
        self.breakdown_epic(
            self.BREAKDOWN.replace('title = "UI shell"\nafter = [1]', 'title = "UI shell"\nafter = [1, "1.1"]')
        )
        status = json.loads(run("status", str(self.initiative)).stdout)
        self.assertEqual(status["unpinned_after"], [])
        self.assertEqual(status["counts"], {"total": 6, "planned": 6})
        self.assertEqual([(e["slug"], e["id"]) for e in status["epics"]], [("epic-pricing", 1), ("epic-cart", 2)])
        self.assertEqual(status["tickets"][0]["blocks"], [2, "2.2"])
        self.assertEqual(status["longest_remaining_chain"][1:], ["2.2", "2.4"])
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = 1\nslug = "epic-pricing"\n\n[[epic]]\nid = 2\nslug = "epic-cart"\nafter = [{ epic = "epic-x" }]\n'
        )
        r = run("status", str(self.initiative))
        self.assertEqual(r.returncode, 1)
        self.assertIn("no epic listed", r.stderr)
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = 2\nslug = "epic-cart"\n\n[[epic]]\nid = 2\nslug = "epic-pricing"\n'
        )
        self.assertIn("two epics with id 2", run("status", str(self.initiative)).stderr)

    def test_status_shows_the_declared_epic_graph_before_and_after_inception(self):
        self.pricing()
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = 1\nslug = "epic-pricing"\n\n'
            '[[epic]]\nid = 2\nslug = "epic-cart"\nafter = [{ epic = 1, needs = "the pricing contract" }]\n'
        )
        for _ in range(2):
            status = json.loads(run("status", str(self.initiative)).stdout)
            self.assertEqual(
                [(e["slug"], e["after"], e["gated_by"]) for e in status["epics"]],
                [
                    ("epic-pricing", [], []),
                    ("epic-cart", [{"epic": "epic-pricing", "needs": "the pricing contract"}], []),
                ],
            )
            self.breakdown_epic()

    def test_an_undeclared_or_backward_cross_epic_after_is_reported(self):
        self.pricing()
        self.breakdown_epic()
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = 1\nslug = "epic-pricing"\n\n[[epic]]\nid = 2\nslug = "epic-cart"\n'
        )
        (self.initiative / "epic-pricing" / "tickets.toml").write_text(
            '[[entry]]\nid = 1\ntype = "story"\ntitle = "Pricing contract"\nafter = ["2.1"]\n'
        )
        for command, folder in (("status", self.initiative), ("next", self.initiative), ("status", self.epic)):
            out = json.loads(run(command, str(folder)).stdout)
            if folder == self.epic:
                self.assertEqual((out["undeclared_after"], out["order_conflict"]), ([], []))
                continue
            self.assertEqual(
                out["undeclared_after"], [{"epic": "epic-pricing", "after": "epic-cart", "ref": "1.1", "names": "2.1"}]
            )
            self.assertEqual(out["order_conflict"], [{"epic": "epic-pricing", "after": "epic-cart"}])
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = 1\nslug = "epic-pricing"\nafter = [{ epic = 2, needs = "the cart" }]\n\n'
            '[[epic]]\nid = 2\nslug = "epic-cart"\n'
        )
        out = json.loads(run("status", str(self.initiative)).stdout)
        self.assertEqual(out["undeclared_after"], [])
        self.assertEqual(out["order_conflict"], [{"epic": "epic-pricing", "after": "epic-cart"}])

    def test_after_naming_a_backlog_file_says_to_move_it_into_the_epic(self):
        self.add("story-thing.md", ticket("", kind="story"), self.add_backlog())
        self.add("story-scaffold.md", ticket("draft", 1, after="[story-thing]"))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("move a backlog ticket into the epic as an entry", json.loads(r.stderr)["error"])
        self.breakdown_epic()
        self.add("story-scaffold.md", ticket("draft", 1, after="[story-ui-shell]"))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("use the entry's id", json.loads(r.stderr)["error"])

    def test_an_epic_gate_counts_once_and_its_order_before_inception(self):
        self.pricing()
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = 1\nslug = "epic-cart"\n\n[[epic]]\nid = 2\nslug = "epic-pricing"\n'
        )
        (self.epic / "epic-cart.md").write_text("---\ntype: epic\nafter: [epic-pricing]\n---\n# epic-cart\n")
        out = json.loads(run("status", str(self.initiative)).stdout)
        self.assertEqual(
            (out["undeclared_after"], out["order_conflict"]), ([], [{"epic": "epic-cart", "after": "epic-pricing"}])
        )
        self.breakdown_epic('[[entry]]\nid = 1\ntype = "story"\ntitle = "Scaffold"\nafter = ["epic-pricing"]\n')
        out = json.loads(run("status", str(self.initiative)).stdout)
        self.assertEqual(
            out["undeclared_after"],
            [{"epic": "epic-cart", "after": "epic-pricing", "ref": "1.1", "names": "epic-pricing"}],
        )
        self.assertEqual(out["order_conflict"], [{"epic": "epic-cart", "after": "epic-pricing"}])

    def test_epics_need_an_id_a_slug_and_a_valid_after(self):
        self.pricing()
        toml = self.initiative / "tickets.toml"
        good = toml.read_text(encoding="utf-8")
        for bad, message in (
            (good.replace("id = 2\n", ""), "needs an `id`"),
            (good.replace('slug = "epic-cart"', 'slug = "epic-pricing"'), "two epics with slug"),
            (good.replace('slug = "epic-cart"\n', ""), "needs a `slug`"),
            (good + "after = [{ epic = true }]\n", "epic = <id or slug>"),
        ):
            toml.write_text(bad, encoding="utf-8")
            r = run("status", str(self.initiative))
            self.assertEqual(r.returncode, 1, r.stdout)
            self.assertIn(message, r.stderr)

    def test_find_prefers_the_folder_asked_about_for_a_file_name(self):
        pricing = self.pricing()
        self.add("story-scaffold.md", ticket("", 1), pricing)
        self.breakdown_epic()
        run("pull", str(self.epic), "1")
        r = run("find", str(pricing), "story-scaffold")
        self.assertEqual(r.returncode, 0, r.stderr)
        self.assertEqual(json.loads(r.stdout)["folder"], "epic-pricing")
        r = run("find", str(self.initiative), "story-scaffold")
        self.assertEqual(r.returncode, 1)
        self.assertIn("more than one", r.stderr)

    def test_a_numeric_tracker_id_is_never_a_sibling_id(self):
        self.seed(s1="done")
        self.add("story-ui-shell.md", ticket("done", 2, tracker_id='"47"'))
        self.add("story-tracer.md", ticket("", 3, after='["47"]'))
        self.assertIn("story-tracer.md", self.files(self.next()["ready_to_refine"]))

    def test_a_quoted_number_is_a_tracker_id_and_a_bare_one_a_sibling(self):
        self.add("story-scaffold.md", ticket("done", 1, tracker_id="101"))
        self.add("story-ui-shell.md", ticket("draft", 2, after='["101"]'))
        self.assertEqual(self.files(self.next()["ready_to_refine"]), ["story-ui-shell.md"])
        self.add("story-ui-shell.md", ticket("draft", 2, after="[101]"))
        self.assertIn("names no entry", run("next", str(self.epic)).stderr)

    def test_an_id_takes_letters_and_digits_wherever_a_number_goes(self):
        pricing = self.add_epic("epic-pricing")
        (pricing / "tickets.toml").write_text(
            '[[entry]]\nid = "6a"\ntype = "story"\ntitle = "Pricing contract"\n\n'
            '[[entry]]\nid = 7\ntype = "story"\ntitle = "Pricing rules"\nafter = ["6a"]\n'
        )
        (self.initiative / "tickets.toml").write_text(
            '[[epic]]\nid = "1b"\nslug = "epic-pricing"\n\n'
            '[[epic]]\nid = 2\nslug = "epic-cart"\nafter = [{ epic = "1b", needs = "prices" }]\n'
        )
        self.breakdown_epic('[[entry]]\nid = "2a"\ntype = "story"\ntitle = "Totals for 6a"\nafter = ["1b.6a"]\n')
        status = json.loads(run("status", str(self.initiative)).stdout)
        rows = {r["ref"]: r for r in status["tickets"]}
        self.assertEqual(list(rows), ["1b.6a", "1b.7", "2.2a"])
        self.assertEqual((rows["1b.7"]["after"], rows["2.2a"]["after"]), (["6a"], ["1b.6a"]))
        self.assertEqual(status["unpinned_after"] + status["undeclared_after"], [])

        self.assertEqual(run("pull", str(pricing), "7").returncode, 0)
        self.assertIn("id: 7\n", (pricing / "story-pricing-rules.md").read_text(encoding="utf-8"))
        self.assertIn("after: [6a]\n", (pricing / "story-pricing-rules.md").read_text(encoding="utf-8"))
        self.assertEqual(run("pull", str(pricing), "6a").returncode, 0)
        self.assertIn("id: 6a\n", (pricing / "story-pricing-contract.md").read_text(encoding="utf-8"))
        self.assertNotIn("drift", json.dumps(self.status_rows(pricing)))

        self.assertEqual([e["id"] for e in self.next()["blocked"]], ["2a"])
        self.add("story-pricing-contract-plan.md", plan("6a", "done"), pricing)
        self.assertEqual([e["id"] for e in self.next()["ready_to_start"]], ["2a"])
        # A bare lettered id names its ticket from any folder, before the title that contains it.
        for folder, ref in ((self.initiative, "1b.6a"), (pricing, "6a"), (self.initiative, "6a"), (self.epic, "6a")):
            found = json.loads(run("find", str(folder), ref).stdout)
            self.assertEqual((found["id"], found["state"]), ("6a", "done"))
        self.assertEqual(run("mark", str(self.epic), "2a", "in-review").returncode, 0)
        self.assertIn("ticket: 2a\n", (self.epic / "story-totals-for-6a-plan.md").read_text(encoding="utf-8"))
        self.assertEqual(self.status_rows(self.epic)[0]["state"], "review")

    def test_two_files_sharing_an_id_error(self):
        self.add("story-a.md", ticket("done", 1))
        self.add("story-b.md", ticket("draft", 1))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("share the id 1", json.loads(r.stderr)["error"])

    def test_malformed_input_returns_json_error(self):
        (self.epic / "story-bad.md").write_bytes(ticket("draft", 1).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-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_leaf_without_id_sorts_last_and_container_file_is_ignored(self):
        self.seed()
        self.add("bug-stray.md", ticket("", 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]["id"])
        self.assertEqual(out["counts"]["total"], 6)

    def mark(self, *args):
        r = run("mark", *args)
        self.assertEqual(r.returncode, 0, r.stderr)
        return json.loads(r.stdout)

    def test_mark_writes_a_plan_beside_the_leaf_and_never_the_leaf(self):
        self.seed()
        leaf = (self.epic / "story-scaffold.md").read_text(encoding="utf-8")
        out = self.mark(str(self.epic), "1", "in-progress", "--assignee", "ann")
        plan_file = self.epic / "story-scaffold-plan.md"
        self.assertEqual(
            out,
            {
                "plan": str(plan_file.resolve()),
                "created": True,
                "status": "in-progress",
                "assignee": "ann",
                "blocked_at": "",
                "blocked_reason": "",
            },
        )
        self.assertEqual((self.epic / "story-scaffold.md").read_text(encoding="utf-8"), leaf)
        self.assertEqual(
            plan_file.read_text(encoding="utf-8"),
            '---\ntitle: "x"\nticket: 1\nstatus: in-progress\nassignee: "ann"\n---\n',
        )
        self.assertEqual(self.files(self.next()["in_progress"]), ["story-scaffold.md"])

    def test_mark_clears_the_leafs_blocking_fields_and_takes_a_literal_assignee(self):
        path = self.epic / "story-scaffold.md"
        path.write_text(ticket("", 1, blocked_at="2026-09-05", blocked_reason="legal"))
        out = self.mark(str(self.epic), "1", "draft", "--assignee", "\\1")
        self.assertEqual(out["assignee"], "\\1")
        text = (self.epic / "story-scaffold-plan.md").read_text(encoding="utf-8")
        self.assertNotIn("blocked_at", text)
        self.assertIn('assignee: "\\\\1"\n', text)
        self.assertIn("blocked_reason", path.read_text(encoding="utf-8"))
        self.assertEqual(self.files(self.next()["ready_to_refine"]), ["story-scaffold.md"])

    def test_mark_on_a_pulled_leaf_keeps_the_leafs_assignee_in_the_new_plan(self):
        self.breakdown_epic()
        run("pull", str(self.epic), "1")
        self.add(
            "story-scaffold.md",
            (self.epic / "story-scaffold.md").read_text().replace("---\n\n", 'assignee: "bob"\n---\n\n', 1),
        )
        self.mark(str(self.epic), "1", "ready-for-dev")
        text = (self.epic / "story-scaffold-plan.md").read_text(encoding="utf-8")
        self.assertEqual(text, '---\ntitle: "Scaffold"\nticket: 1\nstatus: ready-for-dev\nassignee: "bob"\n---\n')
        self.assertEqual(
            [(e["file"], e["assignee"]) for e in self.next()["ready_to_start"]], [("story-scaffold.md", "bob")]
        )

    def test_find_on_an_entry_with_nothing_yet(self):
        self.breakdown_epic(
            self.BREAKDOWN.replace(
                'title = "UI shell"',
                'title = "UI shell"\ndescription = "The page frame."\nverify = "It renders."\n'
                'unknown = "Which grid?"\nreferences = ["SPINE.md#ad-8"]\nnotes = ["Reuse the header."]',
            )
        )
        r = run("find", str(self.epic), "2")
        self.assertEqual(r.returncode, 0, r.stderr)
        hit = json.loads(r.stdout)
        epic = self.epic.resolve()
        self.assertEqual((hit["id"], hit["title"], hit["state"], hit["after"]), (2, "UI shell", "planned", [1]))
        self.assertEqual(
            {k: hit[k] for k in ("folder", "description", "verify", "references", "notes", "unknown")},
            {
                "folder": "epic-cart",
                "description": "The page frame.",
                "verify": "It renders.",
                "references": ["SPINE.md#ad-8"],
                "notes": ["Reuse the header."],
                "unknown": "Which grid?",
            },
        )
        self.assertIsNone(hit["story_file"])
        self.assertEqual(hit["plan"], str(epic / "story-ui-shell-plan.md"))
        self.assertFalse(Path(hit["plan"]).exists())
        self.assertEqual(hit["epic_file"], str(epic / "epic-cart.md"))

    def test_find_names_a_joined_plan_whatever_its_name(self):
        self.breakdown_epic()
        self.add("custom-name.md", plan(2, "in-progress"))
        hit = json.loads(run("find", str(self.epic), "2").stdout)
        self.assertEqual(hit["plan"], str(self.epic.resolve() / "custom-name.md"))
        self.assertEqual(hit["status"], "in-progress")

    def test_find_on_a_refined_entry_names_its_story_file_and_the_plan_after_it(self):
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Scaffold"', 'title = "Scaffold, renamed"'))
        self.add("story-scaffold.md", ticket("", 1))
        hit = json.loads(run("find", str(self.epic), "1").stdout)
        self.assertEqual(hit["story_file"], str(self.epic.resolve() / "story-scaffold.md"))
        self.assertEqual(hit["plan"], str(self.epic.resolve() / "story-scaffold-plan.md"))

    def test_find_on_a_pulled_entry_leaves_the_text_to_the_file(self):
        self.breakdown_epic(
            self.BREAKDOWN.replace(
                'title = "Scaffold"',
                'title = "Scaffold"\ndescription = "The service."\nverify = "It runs."\n'
                'unknown = "Which host?"\nreferences = ["SPINE.md#ad-8"]\nnotes = ["Reuse the mailer."]',
            )
        )
        self.assertEqual(run("pull", str(self.epic), "1").returncode, 0)
        leaf = self.epic / "story-scaffold.md"
        leaf.write_text(leaf.read_text(encoding="utf-8").replace("Which host?", "Which region?"), encoding="utf-8")
        hit = json.loads(run("find", str(self.epic), "1").stdout)
        self.assertEqual([hit[k] for k in ("description", "verify", "references", "notes")], ["", "", [], []])
        self.assertEqual(hit["unknown"], "Which region?")
        self.assertEqual(hit["story_file"], str(self.epic.resolve() / "story-scaffold.md"))

    def test_find_names_the_initiative_file_for_a_leaf_directly_under_it(self):
        solo = self.root / "out" / "initiative-solo"
        solo.mkdir()
        (solo / "initiative-solo.md").write_text("---\ntype: initiative\n---\n# Solo\n")
        (solo / "tickets.toml").write_text('[[entry]]\nid = 1\ntype = "story"\ntitle = "Only"\n')
        hit = json.loads(run("find", str(solo), "1").stdout)
        self.assertEqual(hit["epic_file"], str(solo.resolve() / "initiative-solo.md"))
        self.assertIsNone(hit["story_file"])

    def test_find_and_mark_in_a_backlog(self):
        backlog = self.add_backlog()
        self.add("bug-x.md", ticket("draft", kind="bug"), backlog)
        r = run("find", str(backlog), "bug-x")
        self.assertEqual(r.returncode, 0, r.stderr)
        hit = json.loads(r.stdout)
        self.assertIsNone(hit["epic_file"])
        self.assertEqual(
            [hit[k] for k in ("description", "verify", "references", "notes", "unknown")], ["", "", [], [], ""]
        )
        self.assertEqual(hit["plan"], str(backlog.resolve() / "bug-x-plan.md"))
        self.mark(str(backlog), "bug-x", "in-review")
        self.assertIn('\nticket: "bug-x"\n', (backlog / "bug-x-plan.md").read_text(encoding="utf-8"))
        rows = self.status_rows(backlog)
        self.assertEqual([(t["file"], t["state"]) for t in rows], [("bug-x.md", "review")])

    def test_mark_done_on_a_plan_only_entry_clears_blocking_and_keeps_the_plan(self):
        self.breakdown_epic()
        path = self.epic / "story-ui-shell-plan.md"
        self.add(path.name, plan(2, "blocked", assignee="ann", blocked_at="2026-09-05", blocked_reason="legal"))
        before = path.read_text(encoding="utf-8")
        out = self.mark(str(self.epic), "2", "done")
        self.assertEqual((out["created"], out["status"], out["assignee"]), (False, "done", "ann"))
        text = path.read_text(encoding="utf-8")
        self.assertIn("\nstatus: done\n", text)
        self.assertNotIn("blocked_", text)
        expected = [
            line for line in before.splitlines() if not line.startswith(("status:", "blocked_at:", "blocked_reason:"))
        ]
        self.assertEqual([line for line in text.splitlines() if not line.startswith("status:")], expected)
        rows = self.status_rows(self.epic)
        self.assertEqual(rows[1]["state"], "done")
        out = self.next()
        self.assertNotIn(2, [e["id"] for group in ("ready_to_start", "in_progress", "blocked") for e in out[group]])

    def test_mark_on_a_build_plan_keeps_its_body_and_other_frontmatter(self):
        self.breakdown_epic()
        path = self.epic / "story-scaffold-plan.md"
        text = plan(1, "in-progress").replace("# x\n", "# x\n\nbody: stays\n") + "## Code Review\n"
        self.add(path.name, text)
        self.mark(str(self.epic), "1", "in-review", "--assignee", "ann")
        after = path.read_text(encoding="utf-8")
        self.assertEqual(
            after,
            text.replace(
                "status: 'in-progress' # draft | ready-for-dev | in-progress | in-review | built | done",
                "status: in-review",
            ).replace("\n---\n\n# x", '\nassignee: "ann"\n---\n\n# x'),
        )

    def test_a_blocked_reason_with_colons_and_quotes_reads_back_exactly(self):
        self.breakdown_epic()
        for reason in ('waiting on legal: "terms"', "it's 'odd' \\ and: # not a comment   # still not", "Café ✓
next"):
            out = self.mark(str(self.epic), "2", "blocked", "--blocked", reason)
            self.assertEqual(out["blocked_reason"], reason)
            row = self.next()["blocked"][0]
            self.assertEqual((row["id"], row["status"], row["blocked_reason"]), (2, "blocked", reason))

    def test_mark_with_no_plan_creates_one_that_next_reads(self):
        self.breakdown_epic()
        self.mark(str(self.epic), "2", "ready-for-dev", "--assignee", "ann")
        self.assertEqual(
            (self.epic / "story-ui-shell-plan.md").read_text(encoding="utf-8"),
            '---\ntitle: "UI shell"\nticket: 2\nstatus: ready-for-dev\nassignee: "ann"\n---\n',
        )
        self.assertEqual(
            [(e["id"], e["state"], e["status"], e["assignee"]) for e in self.next()["blocked"]][0],
            (2, "backlog", "ready-for-dev", "ann"),
        )

    def test_mark_blocked_writes_the_design_notes_plan(self):
        self.breakdown_epic()
        self.mark(str(self.epic), "UI shell", "blocked", "--blocked", 'waiting on legal: "terms"')
        today = date.today().isoformat()
        self.assertEqual(
            (self.epic / "story-ui-shell-plan.md").read_text(encoding="utf-8"),
            f'---\ntitle: "UI shell"\nticket: 2\nstatus: blocked\nblocked_at: "{today}"\n'
            'blocked_reason: "waiting on legal: \\"terms\\""\n---\n',
        )

    def test_mark_refuses_an_empty_ref_and_never_matches_digits_against_titles(self):
        self.pricing()
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Scaffold"', 'title = "Phase 2"'))
        r = run("mark", str(self.initiative), "2", "done")
        self.assertEqual(r.returncode, 1, r.stdout)
        self.assertIn("no ticket matches", r.stderr)
        for ref in ("", " "):
            r = run("mark", str(self.epic), ref, "done")
            self.assertEqual(r.returncode, 1, r.stdout)
            self.assertIn("empty", r.stderr)
        self.assertEqual(sorted(p.name for p in self.initiative.rglob("*-plan.md")), [])

    @unittest.skipIf(sys.platform == "win32", "Windows passes arguments as Unicode, so none is undecodable")
    def test_mark_with_an_undecodable_value_leaves_the_plan_unchanged(self):
        self.breakdown_epic()
        path = self.epic / "story-scaffold-plan.md"
        self.add(path.name, plan(1, "in-progress"))
        before = path.read_bytes()
        r = subprocess.run(
            [sys.executable, str(SCRIPT), "mark", str(self.epic), "1", "blocked", "--blocked", b"\xff"],
            capture_output=True,
            check=False,
        )
        self.assertNotEqual(r.returncode, 0, r.stdout)
        self.assertEqual(path.read_bytes(), before)

    def test_mark_refuses_a_plan_name_taken_by_another_file(self):
        self.breakdown_epic()
        for text in (plan(3), "# notes, not a plan\n"):
            self.add("story-ui-shell-plan.md", text)
            r = run("mark", str(self.epic), "2", "done")
            self.assertEqual(r.returncode, 1, r.stdout)
            self.assertIn("story-ui-shell-plan.md", json.loads(r.stderr)["error"])
            self.assertEqual((self.epic / "story-ui-shell-plan.md").read_text(encoding="utf-8"), text)

    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-scaffold.md").write_text(ticket("", 1))
        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), "1", "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), "1", "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_prerequisite_and_bad_status_are_errors(self):
        self.seed()
        self.add("story-scaffold.md", ticket("draft", 1, after="[3]"))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("cycle", r.stderr)
        self.add("story-scaffold.md", ticket("draft", 1, after="[9]"))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("names no entry in epic-cart", r.stderr)
        self.add("story-scaffold.md", ticket("draft", 1, after='["9"]'))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("matches no ticket", r.stderr)
        self.add("story-scaffold.md", ticket("backlog", 1))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("status 'backlog'", r.stderr)
        self.add("story-scaffold.md", ticket("", 1))
        (self.epic / "epic-cart.md").write_text("---\ntype: epic\nstatus: backlog\n---\n")
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1)
        self.assertIn("in-progress, done, dropped", r.stderr)

    def test_entries_with_no_files_start_without_a_pull(self):
        self.breakdown_epic()
        self.add("old-spec.md", "---\ntype: 'feature'\nstatus: 'done'\nsteps:\n  - a\n---\n")
        out = self.next()
        self.assertNotIn("to_pull", out)
        self.assertEqual([(e["id"], e["state"], e["file"]) for e in out["ready_to_start"]], [(1, "planned", None)])
        self.breakdown_epic(self.BREAKDOWN.replace('title = "Scaffold"', 'title = "Scaffold"\nrefine = true'))
        out = self.next()
        self.assertEqual([e["id"] for e in out["ready_to_refine"]], [1])
        self.assertEqual(out["ready_to_start"], [])

    def test_a_plan_sets_the_state_with_or_without_a_story_file(self):
        self.breakdown_epic()
        self.add("story-scaffold-plan.md", plan(1, "done"))
        self.add("story-ui-shell-plan.md", plan(2, "in-review", assignee="ann"))
        for _ in range(2):
            out = self.next()
            self.assertEqual([(e["id"], e["state"], e["assignee"]) for e in out["in_progress"]], [(2, "review", "ann")])
            self.assertEqual([e["id"] for e in out["ready_to_start"]], [3])
            rows = self.status_rows(self.epic)
            self.assertEqual([r["state"] for r in rows], ["done", "review", "planned", "planned"])
            self.add("story-ui-shell.md", ticket("draft", 2, after="[1]", refined="true", assignee='"bob"'))
        self.assertEqual(out["in_progress"][0]["file"], "story-ui-shell.md")
        self.assertEqual(out["in_progress"][0]["status"], "in-review")
        self.add("story-ui-shell.md", ticket("draft", 2, after="[1]", tracker_status="done"))
        self.assertEqual(json.loads(run("status", str(self.epic)).stdout)["tickets"][1]["state"], "done")

    def test_a_built_plan_reads_as_review_and_mark_accepts_built(self):
        self.breakdown_epic()
        self.add("story-scaffold-plan.md", plan(1, "built"))
        out = self.next()
        self.assertEqual([(e["id"], e["status"], e["state"]) for e in out["in_progress"]], [(1, "built", "review")])
        self.assertEqual([e["id"] for e in out["ready_to_start"]], [2, 3])
        self.assertEqual(self.mark(str(self.epic), "2", "built")["status"], "built")
        self.assertEqual(self.status_rows(self.epic)[1]["state"], "review")

    def test_a_plan_with_blocked_at_blocks_its_entry(self):
        self.breakdown_epic()
        self.add("story-scaffold-plan.md", plan(1, "in-progress", blocked_at="2026-09-05", blocked_reason="legal"))
        out = self.next()
        self.assertEqual([(e["id"], e["blocked_reason"]) for e in out["blocked"]][0], (1, "legal"))
        self.assertEqual(out["in_progress"], [])

    def test_a_leaf_with_blocked_at_and_no_plan_is_blocked_with_its_reason(self):
        self.add(
            "story-scaffold.md",
            ticket("in-progress", 1, blocked_at="2026-09-05", blocked_reason="legal"),
        )
        out = self.next()
        self.assertEqual([(e["file"], e["blocked_reason"]) for e in out["blocked"]], [("story-scaffold.md", "legal")])
        self.assertEqual(out["in_progress"], [])

    def test_a_plan_leaving_out_a_field_blanks_the_leafs_value(self):
        self.breakdown_epic()
        self.add("story-scaffold-plan.md", plan(1, "done"))
        self.add(
            "story-ui-shell.md",
            ticket("in-review", 2, after="[1]", blocked_at="2026-09-05", blocked_reason="legal"),
        )
        self.add("story-ui-shell-plan.md", plan(2))
        self.assertNotIn(2, [e["id"] for e in self.next()["blocked"]])
        row = self.status_rows(self.epic)[1]
        self.assertEqual((row["status"], row["state"], row["blocked_reason"]), ("", "backlog", ""))

    def test_a_quoted_numeric_ticket_joins_its_entry_and_a_doubled_quote_reads_as_one(self):
        self.breakdown_epic()
        self.add("story-ui-shell-plan.md", plan(2, "blocked", blocked_reason="can''t"))
        blocked = self.next()["blocked"]
        self.assertEqual([(e["id"], e["status"], e["blocked_reason"]) for e in blocked][0], (2, "blocked", "can't"))

    def test_a_backlog_plan_joins_its_leaf_by_stem_and_a_leaf_without_a_plan_keeps_its_status(self):
        backlog = self.add_backlog()
        self.add("bug-x.md", ticket("draft", kind="bug"), backlog)
        self.add("bug-x-plan.md", plan("bug-x", "in-progress"), backlog)
        self.add("bug-y.md", ticket("in-review", kind="bug"), backlog)
        rows = self.status_rows(backlog)
        self.assertEqual([(r["file"], r["state"]) for r in rows], [("bug-x.md", "in-progress"), ("bug-y.md", "review")])

    def test_two_plans_for_one_ticket_error(self):
        self.breakdown_epic()
        self.add("story-a-plan.md", plan(1))
        self.add("story-b-plan.md", plan(1))
        r = run("next", str(self.epic))
        self.assertEqual(r.returncode, 1, r.stdout)
        for name in ("story-a-plan.md", "story-b-plan.md"):
            self.assertIn(name, json.loads(r.stderr)["error"])

    def test_an_orphan_plan_is_a_problem_and_the_rest_of_the_tree_still_reads(self):
        self.breakdown_epic()
        for ref in (9, "story-nothing"):
            self.add("story-a-plan.md", plan(ref))
            for command in ("next", "status"):
                r = run(command, str(self.epic))
                self.assertEqual(r.returncode, 0, r.stderr)
                out = json.loads(r.stdout)
                self.assertEqual(len(out["problems"]), 1)
                self.assertIn("story-a-plan.md", out["problems"][0])
            self.assertEqual(self.files(self.next()["ready_to_start"]), [None])

    def test_a_plan_with_an_unknown_status_blocks_its_ticket_until_mark_fixes_it(self):
        self.breakdown_epic()
        self.add("story-scaffold-plan.md", plan(1, "already-satisfied"))
        out = self.next()
        self.assertEqual(out["ready_to_start"], [])
        blocked = {t["id"]: t for t in out["blocked"]}
        self.assertIn("already-satisfied", blocked[1]["blocked_reason"])
        self.assertIn("status 'already-satisfied'", out["problems"][0])
        self.assertEqual(run("mark", str(self.epic), "1", "done").returncode, 0)
        out = self.next()
        self.assertNotIn("problems", out)
        self.assertEqual([t["id"] for t in out["ready_to_start"]], [2, 3])


class ActiveInitiativeTests(TreeCase):
    """With no folder, next, status, and find run on `{tickets.root}/{active_initiative}`."""

    def setUp(self):
        super().setUp()
        (self.root / "_bmad" / "scripts").mkdir()
        shutil.copy(CONFIG_UTILS, self.root / "_bmad" / "scripts" / "config_utils.py")
        self.configure("initiative-checkout")
        self.seed()

    def configure(self, initiative, layer="config.toml"):
        line = f'active_initiative = "{initiative}"\n' if initiative is not None else ""
        path = self.root / "_bmad" / ("custom" if layer != "config.toml" else "") / layer
        path.write_text(f'[core]\noutput_folder = "{{project-root}}/out"\n{line}')

    def elsewhere(self):
        other = tempfile.TemporaryDirectory()
        self.addCleanup(other.cleanup)
        return other.name

    def ok(self, r):
        self.assertEqual(r.returncode, 0, r.stderr)
        return json.loads(r.stdout)

    def fails(self, r, message):
        self.assertEqual(r.returncode, 1, r.stdout)
        self.assertIn(message, json.loads(r.stderr)["error"])

    def test_next_with_no_folder_runs_on_the_active_initiative(self):
        expected = self.ok(run("next", str(self.initiative)))
        self.assertEqual(self.ok(run("next", cwd=self.epic)), expected)
        self.assertEqual(expected["folder"], "initiative-checkout")

    def test_with_no_folder_next_and_status_also_report_the_backlog(self):
        self.assertNotIn("backlog", self.ok(run("next", cwd=self.root)))
        backlog = self.add_backlog()
        self.add("bug-x.md", ticket("ready-for-dev", kind="bug", refined="true"), backlog)
        out = self.ok(run("next", cwd=self.root))
        self.assertEqual(out["folder"], "initiative-checkout")
        self.assertEqual(
            (out["backlog"]["folder"], out["backlog"]["ready_to_start"][0]["ref"]), ("backlog", "bug-x.md")
        )
        self.assertEqual(self.ok(run("find", "backlog", "bug-x.md", cwd=self.root))["file"], "bug-x.md")
        out = self.ok(run("status", cwd=self.root))
        self.assertEqual((out["counts"]["total"], out["backlog"]["counts"]), (5, {"total": 1, "backlog": 1}))
        self.assertNotIn("backlog", self.ok(run("next", str(self.initiative))))
        self.add("bug-y.md", ticket("nonsense", kind="bug"), backlog)
        out = self.ok(run("next", cwd=self.root))
        self.assertIn("status", out["backlog"]["error"])
        self.assertEqual(len(out["ready_to_refine"]), 2)

    def test_with_no_folder_a_ticket_the_initiative_lacks_is_found_in_the_backlog(self):
        backlog = self.add_backlog()
        self.add("bug-x.md", ticket("draft", 7, kind="bug", tracker_id='"SHOP-7"'), backlog)
        hit = self.ok(run("find", "bug-x.md", cwd=self.root))
        self.assertEqual((hit["folder"], hit["plan"]), ("backlog", str(backlog.resolve() / "bug-x-plan.md")))
        self.assertEqual(self.ok(run("find", "SHOP-7", cwd=self.root))["ref"], "bug-x.md")
        self.fails(run("find", "bug-nope", cwd=self.root), "no ticket matches")
        self.fails(run("find", str(self.initiative), "bug-x.md"), "no ticket matches")
        self.ok(run("mark", "bug-x.md", "done", cwd=self.root))
        self.assertTrue((backlog / "bug-x-plan.md").is_file())
        self.write_store("jira")
        r = run("mirror", cwd=self.root, stdin=json.dumps([{"tracker_id": "SHOP-7", "tracker_status": "done"}]))
        self.assertEqual([m["ref"] for m in self.ok(r)["mirrored"]], ["bug-x.md"])
        self.assertIn("\ntracker_status: done\n", (backlog / "bug-x.md").read_text(encoding="utf-8"))

    def test_a_backlog_that_cannot_be_read_is_reported_and_the_initiative_still_answers(self):
        backlog = self.add_backlog()
        (backlog / "bug-x.md").write_bytes(b"---\ntype: bug\ntitle: \xff\n---\n")
        out = self.ok(run("status", cwd=self.root))
        self.assertEqual((out["counts"]["total"], out["backlog"]["folder"]), (5, "backlog"))
        self.assertIn("error", out["backlog"])
        self.fails(run("find", "bug-x.md", cwd=self.root), "no ticket matches")
        self.write_store("jira")
        r = run("mirror", cwd=self.root, stdin=json.dumps([{"ref": "story-scaffold", "tracker_status": "done"}]))
        self.assertEqual([m["file"] for m in self.ok(r)["mirrored"]], ["story-scaffold.md"])

    def test_an_epic_is_found_by_its_folder_name_under_the_active_initiative(self):
        out = self.ok(run("--project-root", str(self.root), "status", "epic-cart", cwd=self.elsewhere()))
        self.assertEqual((out["folder"], out["counts"]["total"]), ("epic-cart", 5))
        self.configure(None)
        self.fails(run("--project-root", str(self.root), "status", "epic-cart", cwd=self.elsewhere()), "not a folder")

    def test_the_backlog_as_active_initiative_is_reported_once(self):
        backlog = self.add_backlog()
        self.add("bug-x.md", ticket("draft", kind="bug"), backlog)
        self.configure("backlog")
        out = self.ok(run("status", cwd=self.root))
        self.assertEqual(out["folder"], "backlog")
        self.assertNotIn("backlog", out)

    def test_project_root_flag_names_the_project_from_anywhere(self):
        self.write_store(root="out")
        out = self.ok(run("--project-root", str(self.root), "status", cwd=self.elsewhere()))
        self.assertEqual(out["folder"], "initiative-checkout")
        self.assertEqual(out["counts"]["total"], 5)

    def test_a_relative_folder_is_found_under_the_store_from_anywhere(self):
        out = self.ok(run("--project-root", str(self.root), "status", "initiative-checkout", cwd=self.elsewhere()))
        self.assertEqual(out["folder"], "initiative-checkout")
        out = self.ok(
            run("--project-root", str(self.root), "next", "out/initiative-checkout/epic-cart", cwd=self.elsewhere())
        )
        self.assertEqual(out["folder"], "epic-cart")
        self.fails(
            run("--project-root", str(self.root), "status", "initiative-gone", cwd=self.elsewhere()), "not a folder"
        )

    def test_root_substitutes_project_root_and_output_folder(self):
        for root in ("{project-root}/out", "{output_folder}"):
            self.write_store(root=root)
            self.assertEqual(self.ok(run("status", cwd=self.root))["folder"], "initiative-checkout", root)

    def test_find_by_ref_only(self):
        self.pricing()
        expected = self.ok(run("find", str(self.initiative), "1.2"))
        self.assertEqual(self.ok(run("find", "1.2", cwd=self.root)), expected)
        self.assertEqual(expected["title"], "Pricing rules")

    def test_a_given_folder_never_reads_the_config(self):
        (self.root / "_bmad" / "config.toml").write_text("not toml [")
        self.assertEqual(self.ok(run("next", str(self.epic), cwd=self.root))["folder"], "epic-cart")
        self.fails(run("next", cwd=self.root), "config.toml")

    def test_unset_active_initiative_names_the_key(self):
        for value in (None, ""):
            self.configure(value)
            self.fails(run("next", cwd=self.root), "core.active_initiative")

    def test_no_project_root_found(self):
        self.fails(run("status", cwd=self.elsewhere()), "no project root")

    def test_missing_initiative_folder_names_the_resolved_path(self):
        self.configure("initiative-gone")
        self.fails(run("next", cwd=self.root), str((self.root / "out" / "initiative-gone").resolve()))

    def test_store_is_read_from_the_project_found_when_root_lies_outside_it(self):
        outside = Path(self.elsewhere())
        shutil.copytree(self.initiative, outside / "initiative-checkout")
        self.write_store("linear", outside.as_posix())
        r = run("next", cwd=self.root)
        self.assertEqual(r.returncode, 2, r.stderr)
        self.assertIn("sync ticket status", r.stderr)
        self.assertEqual(self.ok(run("status", cwd=self.root))["store"], "linear")

    def test_the_skill_installs_the_script_and_a_lone_copy_runs(self):
        bmod = tomllib.loads((SCRIPT.parents[1] / "bmod.toml").read_text(encoding="utf-8"))
        self.assertIn("scripts/tickets.py", bmod["skill"]["scripts"])
        installed = self.root / "_bmad" / "method" / "scripts"
        installed.mkdir(parents=True)
        shutil.copy(SCRIPT, installed)
        r = subprocess.run(
            [sys.executable, str(installed / "tickets.py"), "next"],
            text=True,
            capture_output=True,
            check=False,
            cwd=self.root,
        )
        self.assertEqual(self.ok(r)["folder"], "initiative-checkout")

    def test_mark_with_no_folder_blocks_a_ticket_in_the_active_initiative(self):
        pricing = self.pricing()
        r = run("mark", "1.2", "in-progress", "--blocked", "legal", cwd=self.root)
        self.assertEqual(self.ok(r)["plan"], str(pricing.resolve() / "story-pricing-rules-plan.md"))
        text = (pricing / "story-pricing-rules-plan.md").read_text(encoding="utf-8")
        self.assertIn(f'\nblocked_at: "{date.today().isoformat()}"\nblocked_reason: "legal"\n', text)
        blocked = self.ok(run("next", cwd=self.root))["blocked"]
        self.assertIn(
            ("epic-pricing", 2, "in-progress", "legal"),
            [(e["epic"], e["id"], e["status"], e["blocked_reason"]) for e in blocked],
        )

    def test_user_layer_overrides_the_base_config(self):
        self.configure("initiative-gone")
        self.configure("initiative-checkout", layer="config.user.toml")
        self.assertEqual(self.ok(run("next", cwd=self.root))["folder"], "initiative-checkout")


if __name__ == "__main__":
    unittest.main()
scripts/tickets.py›
#!/usr/bin/env python3
# /// script
# requires-python = ">=3.11"
# ///
"""tickets — read a ticket tree and answer what is next.

A container folder holds its ticket file, `tickets.toml`, flat leaf files named `<type>-<slug>.md`,
and the builds' plan files. An epic's `tickets.toml` lists its planned leaves as `[[entry]]` tables
(`id`, `type`, `title`, `after`, and whatever else the plan records); an initiative's lists its
epics as `[[epic]]` tables (`id`, `slug`, `after = [{epic, needs}]`). Tables are in build order.
`id` names an entry for good and is never reused: a number, or letters and digits such as `6a`, and
`6` and `"6"` are one id. A leaf file carries it in frontmatter, which is how the file joins its
entry. An entry needs no leaf file to start. A ticket needs refining before it starts when it is a
bug, its entry says `refine = true`, or it has no entry. A leaf file's frontmatter adds
`tracker_status` and `refined`; its `after` and `hitl` replace the entry's, absent reading as empty,
and a difference from the entry is `drift` on the row, which gives the file's and the entry's
value of each field that differs. An entry's `unknown` is a question to settle before the ticket starts;
once the entry is pulled it is the `Unknown:` lines of the file's Notes. A row carries it, and next holds
the ticket under `blocked` until it is gone. A row also carries `plan_checkpoint` and `done_checkpoint`
when the entry sets them: the orchestrator of an unattended run reads them, and no build does. `risk`
(low, medium, high) is the ticket's proposed risk, the leaf file's once it is pulled; the build settles it
in its plan.

A plan is any other `.md` whose frontmatter has `ticket` and whose `type` is not a leaf type. A
`ticket` that is an id joins the entry with that id in the plan's folder; any other string joins the
leaf file with that stem (a backlog leaf). A plan is never a row of its own. It holds the ticket's
`status`, `assignee`, `blocked_at`, and `blocked_reason`; a leaf file's own fields are read only when
the ticket has no plan, and a plan with no `assignee` key leaves the leaf file's. A plan whose `ticket`
names nothing is skipped, and one whose `status` is unknown blocks its ticket; next and status list both
under `problems`.

`status` is draft, ready-for-dev, in-progress, in-review, or built from the builds (built is their
last: the build finished and nobody has called it done), blocked from build-auto, done from the
user or an orchestrator through mark, or dropped; absent means no build has started.
On a tracker store `tracker_status` mirrors the tracker's word (backlog, in-progress, review, done,
dropped). A ticket's `state` is `planned` with no file and no plan, else `tracker_status`, else
derived from `status`: absent, draft, ready-for-dev -> backlog; in-progress, blocked -> in-progress;
in-review, built -> review; done; dropped.

`after` lists real prerequisites: a sibling's id (a number bare, since a quoted number is a tracker
id; an id with a letter quoted), or a quoted string that is `<epic id>.<entry id>` for an entry in
another epic of the same initiative, `epic-<slug>` for that whole epic, a sibling's file name, or a
tracker id. An epic file's own `after` names epics and holds every ticket under it; rows show it as `gated_by`. A dropped prerequisite still blocks.

On an initiative, or an epic it lists, next and status report `unpinned_after` (a declared epic
`after` no entry of the waiting epic pins), `undeclared_after` (an entry's `after` into an epic its
own epic does not declare), and `order_conflict` (an epic that waits on one later in build order).
status's `epics` rows carry the declared `after` with its `needs`, and the epic file's own gate as
`gated_by`.

  next   [<dir>]                 tickets whose prerequisites are done or in review, grouped by state, in
                                 build order; an epic's own `after` waits for that epic to be done. A
                                 `blocked` row's `waiting_on` lists its unmet prerequisites
  status [<dir>]                 every ticket in build order, what it blocks, counts by state, longest chain,
                                 `next_id` (the id a new ticket in the folder takes: one past the highest
                                 number used, or `next_id` at the top of its `tickets.toml` when that is
                                 higher)
  find   [<dir>] <ref>           the one ticket a reference names, with its entry's text fields (empty once
                                 the entry is pulled: the file holds them) and the absolute paths
                                 `epic_file` (its container's file), `story_file` (null until pulled), and
                                 `plan` (where its plan is or goes)
  pull   <dir> <id>              write entry id's leaf file: `after` and `hitl` always, other fields only
                                 when the entry sets them; no status
  mark   [<dir>] <ref> <status> [--assignee <who>] [--blocked <reason>]
                                 set a ticket's status in its plan, creating a frontmatter-only plan when
                                 there is none; --blocked sets blocked_at and blocked_reason, else both are
                                 cleared (repo store only)
  mirror [<dir>]                 write what a tracker returned into leaf files, pulling a file when the
                                 entry has none. Stdin is a JSON array, one object per ticket: `ref` (or
                                 `tracker_id` alone, for a ticket that already carries it) and any of
                                 `tracker_id`, `remote`, `tracker_status`, `assignee`, `after`. Only the
                                 keys given are written, and never `status` (tracker stores only)

`<dir>` is an epic folder, a backlog folder, or an initiative folder (all its epics). `<ref>` is
`<epic id>.<entry id>`, an entry id inside an epic folder (one with a letter, from any folder), a
tracker id, a file name, or an unbroken phrase from the title that matches one ticket. Each row of
next, status, and find carries `ref`, a reference find resolves in the folder the command ran on.
`--project-root` names the project holding `_bmad/` when the tickets live outside it. A relative
`<dir>` that is not a folder under the working directory is looked up under `{output_folder}`, then
the active initiative, then the project root, so an epic's folder name alone finds it.

With no `<dir>`, next, status, find, mark, and mirror run on the active initiative,
`{output_folder}/{active_initiative}`. next and status then also report `backlog`, the same view of
`{output_folder}/backlog` when that folder exists, each row's `ref` its file name; find, mark, and mirror
look there for a ticket the initiative does not hold.
The project root is `--project-root`, else the first folder at or above the working directory that
holds `_bmad/`. `active_initiative` and `output_folder` (`[core]`) come from the
BMad config, merged by the project's `_bmad/scripts/config_utils.py`. `{project-root}` is
substituted, and a relative path is taken from the project root.

Output is one JSON object on stdout. Exit 0 on success, 1 on a malformed tree, 2 when
the store forbids the operation.
"""

import argparse
import codecs
import importlib.util
import json
import os
import re
import sys
import tomllib
import unicodedata
from datetime import date
from pathlib import Path

sys.dont_write_bytecode = True

STATUSES = ("draft", "ready-for-dev", "in-progress", "in-review", "built", "done", "blocked", "dropped")
STATES = ("backlog", "in-progress", "review", "done", "dropped")
CONTAINER_STATUSES = ("in-progress", "done", "dropped")
STATE_OF = {
    "": "backlog",
    "draft": "backlog",
    "ready-for-dev": "backlog",
    "in-progress": "in-progress",
    "blocked": "in-progress",
    "in-review": "review",
    "built": "review",
    "done": "done",
    "dropped": "dropped",
}
LEAF_TYPES = ("story", "spike", "bug")
CONTAINER_TYPES = ("initiative", "epic")
NAME_RE = re.compile(r"^(story|spike|bug)-(.+)\.md$")
ID_RE = re.compile(r"^[0-9A-Za-z]+$")
CROSS_RE = re.compile(r"^([0-9A-Za-z]+)\.([0-9A-Za-z]+)$")
EPIC_RE = re.compile(r"^epic-[^/]+$")
BREAKDOWN = "tickets.toml"
QUOTED_COMMENT_RE = re.compile(r"""^("(?:[^"\\]|\\.)*"|'(?:[^']|'')*')\s+#.*$""")
FRONTMATTER_RE = re.compile(r"\A---\n(.*?)\n---(?:\n|\Z)", re.S)
PLAN_FIELDS = ("status", "assignee", "blocked_at", "blocked_reason")
COMMENT_RE = re.compile(r"<!--.*?-->", re.S)  # a template's example sits in one
UNKNOWN_RE = re.compile(r"^[ \t]*(?:[-*][ \t]+)?Unknown:[ \t]*(\S.*)$", re.M)


class TicketError(Exception):
    """`data` is added to the error object the command prints."""

    def __init__(self, message: str, **data):
        super().__init__(message)
        self.data = data


class NoMatch(TicketError):
    pass


class StoreRefusal(Exception):
    pass


# ---------------------------------------------------------------- frontmatter


def parse_frontmatter(text: str, lenient: bool = False) -> dict:
    """Minimal YAML subset: `key: value`, lists as `[a, b]`, quoted or bare scalars. Lenient
    skips block lists instead of refusing them, for plans written from the build's template."""
    m = FRONTMATTER_RE.match(text)
    if not m:
        return {}
    data = {}
    for line in m.group(1).splitlines():
        if lenient and (line[:1].isspace() or line.startswith("- ")):
            continue
        if line.lstrip().startswith("- "):
            raise TicketError("frontmatter lists must be inline: `key: [a, b]`")
        if not line.strip() or line.lstrip().startswith("#") or ":" not in line:
            continue
        key, _, value = line.partition(":")
        value = value.split("   #")[0].strip()
        quoted = QUOTED_COMMENT_RE.match(value)
        data[key.strip()] = _scalar(quoted.group(1) if quoted else 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 "\"'":
        if value[0] == '"':
            try:
                return str(json.loads(value))
            except ValueError:
                pass
        return value[1:-1] if value[0] == '"' else value[1:-1].replace("''", "'")
    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 = FRONTMATTER_RE.match(text)
    if not m:
        raise TicketError("ticket has no frontmatter")
    block = m.group(1)
    pattern = re.compile(rf"^{re.escape(key)}:.*$\n?", re.M)
    if value == "":
        block = pattern.sub("", block).rstrip("\n")
    elif pattern.search(block):
        block = pattern.sub(lambda _: f"{key}: {value}\n", block, count=1).rstrip("\n")
    else:
        block = f"{block}\n{key}: {value}"
    return text[: m.start(1)] + block + text[m.end(1) :]


def _list(value, where: str) -> list:
    if value in (None, ""):
        return []
    if not isinstance(value, list):
        raise TicketError(f"{where}: after must be a list")
    return value


def _flag(value) -> bool:
    return str(value).lower() == "true"


def _one_of(value, allowed: tuple, where: str, field: str):
    """`value` when it is absent (`""`) or one of `allowed`; else the error naming them."""
    if value not in ("", *allowed):
        raise TicketError(f"{where}: {field} {value!r} is not one of {', '.join(allowed)}")
    return value


# ---------------------------------------------------------------- loading


def read_text(path: Path) -> str:
    # utf-8-sig: Windows editors can save a byte-order mark, which would hide the frontmatter.
    return path.read_text(encoding="utf-8-sig")


def load_breakdown(folder: Path) -> dict:
    path = folder / BREAKDOWN
    if not path.is_file():
        return {}
    where = f"{folder.name}/{BREAKDOWN}"
    try:
        data = tomllib.loads(read_text(path))
    except tomllib.TOMLDecodeError as e:
        raise TicketError(f"{where}: {e}") from e
    floor = data.get("next_id", 0)
    if isinstance(floor, bool) or not isinstance(floor, int):
        raise TicketError(f"{where}: `next_id` must be a whole number")
    for table in ("entry", "epic"):
        rows = data.get(table, [])
        if not isinstance(rows, list) or not all(isinstance(r, dict) for r in rows):
            raise TicketError(f"{where}: write `[[{table}]]` tables, one per {table}")
        for r in rows:
            for key in ("covers", "after", "references", "notes"):
                if not isinstance(r.get(key, []), list):
                    raise TicketError(f"{where}: `{key}` must be a list")
            r["id"] = _id(r.get("id"))
            if r["id"] is None:
                raise TicketError(f"{where}: every {table} needs an `id`: a number, or letters and digits")
            if table == "epic":
                if not isinstance(r.get("slug"), str) or not r["slug"]:
                    raise TicketError(f"{where}: epic {r['id']} needs a `slug`")
                for a in r.get("after", []):
                    if not isinstance(a, dict) or (_id(a.get("epic")) is None and not isinstance(a.get("epic"), str)):
                        raise TicketError(
                            f'{where}: an epic\'s `after` takes tables: [{{ epic = <id or slug>, needs = "..." }}]'
                        )
    return data


def _id(value):
    """An id as the tree compares it, else None: a number, or letters and digits; digits alone are the number."""
    if isinstance(value, bool):
        return None
    if isinstance(value, int):
        return value
    # A frontmatter value of true or false is read as a boolean, so neither can be an id.
    if isinstance(value, str) and ID_RE.match(value) and value not in ("true", "false"):
        return int(value) if value.isdigit() else value
    return None


def load_container(folder: Path) -> dict:
    path = folder / f"{folder.name}.md"
    if not path.is_file():
        raise TicketError(f"{folder.name}: no {path.name}")
    fm = parse_frontmatter(read_text(path))
    if fm.get("type") not in CONTAINER_TYPES:
        raise TicketError(
            f"{folder.name}/{path.name}: type {fm.get('type')!r} is not one of {', '.join(CONTAINER_TYPES)}"
        )
    status = _one_of(fm.get("status", ""), CONTAINER_STATUSES, f"{folder.name}/{path.name}", "status")
    return {
        "slug": folder.name,
        "tracker_id": str(fm.get("tracker_id", "") or ""),
        "status": status,
        "raw_after": _list(fm.get("after"), f"{folder.name}.md"),
    }


def file_unknown(text: str) -> str:
    """The `Unknown:` lines of a leaf file's body, as `pull` writes an entry's `unknown` into Notes."""
    body = COMMENT_RE.sub("", FRONTMATTER_RE.sub("", text, count=1))
    return "; ".join(m.group(1).strip() for m in UNKNOWN_RE.finditer(body))


def load_folder(folder: Path, problems: list[str]) -> list[dict]:
    """One row per ticket in a folder, in build order: every breakdown entry, joined to its
    leaf file when one exists, then leaf files the breakdown does not list. Plans then set
    the status fields of the rows they join."""
    where = folder.name
    rows = {}
    for e in load_breakdown(folder).get("entry", []):
        n, kind = e["id"], e.get("type")
        if kind not in LEAF_TYPES:
            raise TicketError(f"{where}/{BREAKDOWN}: entry {n} type {kind!r} is not one of {', '.join(LEAF_TYPES)}")
        if n in rows:
            raise TicketError(f"{where}/{BREAKDOWN}: two entries with id {n}")
        rows[n] = {
            "epic": where,
            "id": n,
            "file": None,
            "type": kind,
            "tracker_id": "",
            "title": str(e.get("title", "")),
            "status": "",
            "tracker_status": "",
            "state": "planned",
            "assignee": "",
            "refined": False,
            "refine": kind == "bug" or _flag(e.get("refine", False)),
            "description": str(e.get("description", "")),
            "verify": str(e.get("verify", "")),
            "unknown": str(e.get("unknown", "")),
            "references": [str(v) for v in e.get("references", [])],
            "notes": [str(v) for v in e.get("notes", [])],
            "risk": str(e.get("risk", "")),
            "hitl": _flag(e.get("hitl", False)),
            "plan_checkpoint": _flag(e.get("plan_checkpoint", False)),
            "done_checkpoint": _flag(e.get("done_checkpoint", False)),
            "covers": [str(c) for c in e.get("covers", [])],
            "estimate": e.get("estimate", ""),
            "blocked_at": "",
            "blocked_reason": "",
            "raw_after": _list(e.get("after"), f"{where}/{BREAKDOWN} entry {n}"),
            "entry_after": None,
        }
    unlisted, stray, plans = {}, [], []
    seen = {}
    for path in sorted(folder.glob("*.md")):
        text = read_text(path)
        fm = parse_frontmatter(text, lenient=True)
        if not fm and text.startswith("---") and NAME_RE.match(path.name):
            raise TicketError(f"{where}/{path.name}: frontmatter does not close")
        if fm.get("type") not in LEAF_TYPES:
            if "ticket" in fm:
                plans.append((path.name, fm))
            continue
        try:
            fm = parse_frontmatter(text)
        except TicketError as e:
            raise TicketError(f"{where}/{path.name}: {e}") from e
        status = _one_of(fm.get("status", ""), STATUSES, f"{where}/{path.name}", "status")
        tracker_status = _one_of(fm.get("tracker_status", ""), STATES, f"{where}/{path.name}", "tracker_status")
        n = _id(fm.get("id"))
        if n is not None:
            if n in seen:
                raise TicketError(f"{seen[n]} and {path.name} share the id {n}")
            seen[n] = path.name
        row = rows.get(n) if n is not None else None
        if row is None:
            row = {"epic": where, "id": n, "raw_after": [], "entry_after": None, "covers": [], "title": ""}
            row["refine"] = True
            if n is None:
                stray.append(row)
            else:
                unlisted[n] = row
        else:
            row["entry_after"] = row["raw_after"]
            row["entry_hitl"] = row["hitl"]
        row.update(
            {
                "file": path.name,
                "type": fm.get("type"),
                "tracker_id": str(fm.get("tracker_id", "") or ""),
                "title": str(fm.get("title", "") or row["title"]),
                "status": status,
                "tracker_status": tracker_status,
                "state": tracker_status or STATE_OF[status],
                "assignee": str(fm.get("assignee", "") or ""),
                "refined": _flag(fm.get("refined", False)),
                "refine": row["refine"] or fm.get("type") == "bug",
                "hitl": _flag(fm.get("hitl", False)),
                "covers": [str(c) for c in fm["covers"]] if isinstance(fm.get("covers"), list) else row["covers"],
                "estimate": fm.get("estimate", row.get("estimate", "")),
                "risk": fm["risk"] if isinstance(fm.get("risk"), str) else row.get("risk", ""),
                "blocked_at": fm.get("blocked_at", ""),
                "blocked_reason": str(fm.get("blocked_reason", "") or ""),
                "unknown": file_unknown(text),
            }
        )
        row["raw_after"] = _list(fm.get("after", []), f"{where}/{path.name}")
    out = list(rows.values()) + [unlisted[n] for n in sorted(unlisted, key=lambda n: (isinstance(n, str), n))] + stray
    join_plans(out, plans, where, problems)
    return out


def join_plans(rows: list[dict], plans: list[tuple[str, dict]], where: str, problems: list[str]) -> None:
    """Set each plan's status fields on the one row its `ticket` names; a bad plan is a problem, not an error."""
    for name, fm in plans:
        ticket = fm["ticket"]
        row = None
        if _id(ticket) is not None:
            ticket = _id(ticket)
            row = next((r for r in rows if r["id"] == ticket), None)
        if row is None and isinstance(ticket, str) and ticket:
            row = next((r for r in rows if r["file"] == f"{ticket}.md"), None)
        if row is None:
            problems.append(f"{where}/{name}: ticket {ticket!r} names no entry or leaf file in {where}; skipped")
            continue
        if "plan" in row:
            raise TicketError(f"{where}/{row['plan']} and {name} are both plans for ticket {ticket!r}")
        fields = {k: str(fm.get(k, "") or "") for k in PLAN_FIELDS}
        if "assignee" not in fm:
            # A tracker's assignee is mirrored into the leaf file; the builds' plans carry no assignee.
            fields["assignee"] = row.get("assignee", "")
        try:
            _one_of(fields["status"], STATUSES, f"{where}/{name}", "status")
        except TicketError as e:
            problems.append(f"{e}; the ticket reads as blocked until the plan is fixed")
            fields.update(status="blocked", blocked_reason=f"{name} has an unknown status {fields['status']!r}")
        row.update({"plan": name, "state": row["tracker_status"] or STATE_OF[fields["status"]], **fields})


def epic_folders(initiative: Path) -> list[Path]:
    return sorted(
        d
        for d in initiative.glob("epic-*")
        if d.is_dir() and ((d / f"{d.name}.md").is_file() or (d / BREAKDOWN).is_file())
    )


def load_tree(folder: Path) -> dict:
    """The folder asked about plus every epic its tickets can name."""
    epics = epic_folders(folder)
    if epics or "epic" in load_breakdown(folder):
        scope, initiative, folders = None, folder, None
    elif folder in epic_folders(folder.parent):
        scope, initiative, folders = folder.name, folder.parent, epic_folders(folder.parent)
        epics = folders
    else:
        scope, initiative, folders = folder.name, None, [folder]
    listed = load_breakdown(initiative).get("epic", []) if initiative else []
    order = [e.get("slug") for e in listed]
    epics.sort(key=lambda d: (order.index(d.name) if d.name in order else len(order), d.name))
    if folders is None:
        folders = [*epics, folder]
    epic_ids = {}
    for e in listed:
        if e["id"] in epic_ids.values():
            raise TicketError(f"{initiative.name}/{BREAKDOWN}: two epics with id {e['id']}")
        if e["slug"] in epic_ids:
            raise TicketError(f"{initiative.name}/{BREAKDOWN}: two epics with slug {e['slug']}")
        epic_ids[e["slug"]] = e["id"]
    problems = []
    tickets = [t for f in folders for t in load_folder(f, problems)]
    for t in tickets:
        t["key"] = f"{t['epic']}/{t['id']}" if t["id"] is not None else f"{t['epic']}/{t['file']}"
    tree = {
        "scope": scope,
        "initiative": initiative,
        "folders": {f.name: f for f in folders},
        "epic_ids": epic_ids,
        "containers": {f.name: load_container(f) for f in epics},
        "tickets": tickets,
        "problems": problems,
    }
    _resolve(tree)
    _check_cycles(tickets, tree["containers"])
    return tree


def _resolve(tree: dict) -> None:
    tickets, containers = tree["tickets"], tree["containers"]
    by_key = {t["key"]: t for t in tickets}
    slugs = {i: slug for slug, i in tree["epic_ids"].items()}
    ids = {c["tracker_id"]: slug for slug, c in containers.items() if c["tracker_id"]}
    ids.update({t["tracker_id"]: t["key"] for t in tickets if t["tracker_id"]})
    owners: dict[str, list[str]] = {}
    carriers = [(s, c["tracker_id"]) for s, c in containers.items()] + [(t["key"], t["tracker_id"]) for t in tickets]
    for key, tid in carriers:
        if tid:
            owners.setdefault(tid.lower(), []).append(key)
    for tid, keys in owners.items():
        if len(keys) > 1:
            raise TicketError(f"tracker_id {tid!r} is on more than one ticket: {', '.join(keys)}")

    def sibling(t, ref, where):
        """A bare number is always a sibling's id and a quoted one never is; an id with a letter is one when a
        sibling has it."""
        mates = [o for o in tickets if o["epic"] == t["epic"]]
        if isinstance(ref, int) and not isinstance(ref, bool):
            hit = next((o for o in mates if o["id"] == ref), None)
            if hit is None:
                raise TicketError(f"{where}: after {ref!r} names no entry in {t['epic']}")
            return hit["key"]
        for o in mates:
            if isinstance(o["id"], str) and o["id"] == ref:
                return o["key"]
        for o in mates:
            if o["file"] and ref in (o["file"], o["file"][:-3]):
                return o["key"]
        return None

    def resolve(t, refs, where):
        keys = []
        for ref in refs:
            text = str(ref)
            key = sibling(t, ref, where) if "id" in t else None
            m = CROSS_RE.match(text)
            slug = slugs.get(_id(m.group(1))) if m else None
            if key is None and slug:
                key = f"{slug}/{_id(m.group(2))}"
                if key not in by_key:
                    raise TicketError(f"{where}: after {ref!r} names no entry in {slug}")
            if key is None and EPIC_RE.match(text):
                if text not in containers:
                    raise TicketError(f"{where}: after {ref!r} names no epic in this initiative")
                key = text
            if key is None:
                key = ids.get(text)
            if key is None and m:
                raise TicketError(f"{where}: after {ref!r} names no epic id in this initiative's {BREAKDOWN}")
            if key is None and NAME_RE.match(text if text.endswith(".md") else f"{text}.md"):
                raise TicketError(
                    f"{where}: after {ref!r} matches no ticket in {t['epic']}; a file name names a pulled ticket in the "
                    "same folder only: use the entry's id, or move a backlog ticket into the epic as an entry"
                )
            if key is None:
                raise TicketError(f"{where}: after {ref!r} matches no ticket")
            if key not in keys:
                keys.append(key)
        return keys

    for t in tickets:
        where = f"{t['epic']}/{t['file']}" if t["file"] else f"{t['epic']}/{BREAKDOWN} entry {t['id']}"
        t["after"] = resolve(t, t.pop("raw_after"), where)
        planned = t.pop("entry_after")
        t["gated_by"] = []
        entry_hitl = t.pop("entry_hitl", None)
        t["drift"] = {}
        if planned is not None:
            entry_after = resolve(t, planned, where)
            if sorted(entry_after) != sorted(t["after"]):
                t["drift"]["after"] = {"file": t["after"], "entry": entry_after}
            if entry_hitl != t["hitl"]:
                t["drift"]["hitl"] = {"file": t["hitl"], "entry": entry_hitl}
    for slug, c in containers.items():
        gates = resolve({"epic": slug}, c.pop("raw_after"), f"{slug}.md")
        c["after"] = gates
        for t in tickets:
            if t["epic"] == slug:
                t["gated_by"] = gates


def _check_cycles(tickets: list[dict], containers: dict) -> None:
    sys.setrecursionlimit(max(1000, 3 * len(tickets) + 100))
    graph = {t["key"]: t["after"] + t["gated_by"] for t in tickets}
    members = {}
    for t in tickets:
        members.setdefault(t["epic"], []).append(t["key"])
    for slug, c in containers.items():
        members.setdefault(slug, []).extend(c["after"])
    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.get(node, members.get(node, [])):
            visit(b, path + [node])
        state[node] = "done"

    for node in graph:
        visit(node, [])


# ---------------------------------------------------------------- views


def done_keys(tree: dict) -> set:
    done = {t["key"] for t in tree["tickets"] if t["state"] == "done"}
    return done | {slug for slug, c in tree["containers"].items() if c["status"] == "done"}


def in_scope(tree: dict) -> list[dict]:
    return [t for t in tree["tickets"] if tree["scope"] in (None, t["epic"])]


def classify(tree: dict) -> dict:
    done = done_keys(tree)
    # A ticket in review meets an `after`; an epic gate still waits for the epic to be done.
    met = done | {t["key"] for t in tree["tickets"] if t["state"] == "review"}
    groups = {"ready_to_refine": [], "ready_to_start": [], "in_progress": [], "blocked": []}
    for t in in_scope(tree):
        s = t["state"]
        if s in ("done", "dropped"):
            continue
        unmet = [b for b in t["after"] if b not in met] + [b for b in t["gated_by"] if b not in done]
        if t["status"] == "blocked" or t["blocked_at"]:
            t["waiting_on"] = unmet
            groups["blocked"].append(t)
        elif s in ("in-progress", "review"):
            groups["in_progress"].append(t)
        elif unmet:
            t["waiting_on"] = unmet
            groups["blocked"].append(t)
        elif t["refine"] and not t["refined"]:
            groups["ready_to_refine"].append(t)  # refining is where its unknown gets settled
        elif t.get("unknown"):
            groups["blocked"].append(t)
        else:
            groups["ready_to_start"].append(t)
    return groups


def longest_remaining_chain(tree: dict) -> list[str]:
    remaining = {t["key"]: t for t in tree["tickets"] if t["state"] not in ("done", "dropped")}
    memo = {}

    def chain(k):
        if k in memo:
            return memo[k]
        best = []
        for b in remaining[k]["after"] + remaining[k]["gated_by"]:
            if b in remaining:
                c = chain(b)
                if len(c) > len(best):
                    best = c
        memo[k] = best + [k]
        return memo[k]

    longest = []
    for t in in_scope(tree):
        if t["key"] in remaining:
            c = chain(t["key"])
            if len(c) > len(longest):
                longest = c
    return [ref(k, None, tree) for k in longest]


def ref(key: str, epic: str | None, tree: dict) -> str | int:
    """A key as the plan writes it: a sibling's id, `<epic id>.<id>` elsewhere, an epic's slug."""
    slug, _, n = key.partition("/")
    if not n:
        return slug
    if _id(n) is None:
        return key
    if slug == epic:
        return _id(n)
    if slug in tree["epic_ids"]:
        return f"{tree['epic_ids'][slug]}.{n}"
    return key


def declared_after(tree: dict) -> dict:
    """Each epic the initiative's `tickets.toml` lists, in build order, with its `after` as `[{epic, needs}]`."""
    if not tree["initiative"]:
        return {}
    listed = load_breakdown(tree["initiative"]).get("epic", [])
    slugs = [e["slug"] for e in listed]
    by_id = {i: slug for slug, i in tree["epic_ids"].items()}
    out = {}
    for e in listed:
        out[e["slug"]] = []
        for a in e.get("after", []):
            needed = by_id.get(_id(a.get("epic")), a.get("epic"))
            if needed not in slugs:
                raise TicketError(
                    f"{tree['initiative'].name}/{BREAKDOWN}: {e['slug']} is after {a.get('epic')!r}, which is no epic listed"
                )
            out[e["slug"]].append({"epic": needed, "needs": a.get("needs", "")})
    return out


def unpinned_after(tree: dict, declared: dict) -> list[dict]:
    """Declared `after` lines whose waiting epic has tickets but none waiting on the named epic."""
    out = []
    for slug, edges in declared.items():
        mine = [t for t in tree["tickets"] if t["epic"] == slug]
        for a in edges:
            needed = a["epic"]
            pinned = any(b == needed or b.startswith(f"{needed}/") for t in mine for b in t["after"] + t["gated_by"])
            if mine and not pinned and tree["scope"] in (None, slug):
                out.append({"epic": slug, "after": needed, "needs": a["needs"]})
    return out


def cross_epic_after(tree: dict, declared: dict) -> dict:
    """`undeclared_after`: an entry's `after` into an epic its epic does not declare. `order_conflict`: an epic
    that waits, declared or through its tickets, on an epic later in the initiative's build order."""
    order = list(declared)
    undeclared, conflicts = [], []

    def conflict(epic, needed):
        pair = {"epic": epic, "after": needed}
        if order.index(needed) > order.index(epic) and pair not in conflicts and tree["scope"] in (None, epic):
            conflicts.append(pair)

    for slug, edges in declared.items():
        for a in edges:
            conflict(slug, a["epic"])
    for slug, c in tree["containers"].items():
        for b in c["after"]:
            if slug in declared and b.partition("/")[0] in declared:
                conflict(slug, b.partition("/")[0])
    for t in tree["tickets"]:
        if t["epic"] not in declared:
            continue
        allowed = {a["epic"] for a in declared[t["epic"]]}
        for b in t["after"]:
            needed = b.partition("/")[0]
            if needed == t["epic"] or needed not in declared:
                continue
            conflict(t["epic"], needed)
            if needed not in allowed and tree["scope"] in (None, t["epic"]):
                undeclared.append(
                    {"epic": t["epic"], "after": needed, "ref": row_ref(t, tree), "names": ref(b, t["epic"], tree)}
                )
    return {"undeclared_after": undeclared, "order_conflict": conflicts}


def row_ref(t: dict, tree: dict) -> str | None:
    """What `find` resolves to this ticket in the folder the command ran on; never the title,
    which can repeat across epics."""
    if t["id"] is not None and t["epic"] in tree["epic_ids"]:
        return f"{tree['epic_ids'][t['epic']]}.{t['id']}"
    if t["id"] is not None and t["epic"] == tree["scope"]:
        return str(t["id"])
    return t["file"]


def public(t: dict, tree: dict, blocks: dict | None = None) -> dict:
    row = {
        k: t[k]
        for k in (
            "epic",
            "id",
            "file",
            "type",
            "tracker_id",
            "title",
            "status",
            "tracker_status",
            "state",
            "assignee",
            "hitl",
            "risk",
            "covers",
            "estimate",
            "refine",
            "refined",
            "blocked_at",
            "blocked_reason",
        )
    }
    row["ref"] = row_ref(t, tree)
    row["after"] = [ref(b, t["epic"], tree) for b in t["after"]]
    if t["gated_by"]:
        row["gated_by"] = t["gated_by"]
    if blocks is not None:
        row["blocks"] = [ref(b, t["epic"], tree) for b in blocks.get(t["key"], [])]
    if t["drift"]:
        row["drift"] = dict(t["drift"])
        if "after" in row["drift"]:
            row["drift"]["after"] = {k: [ref(b, t["epic"], tree) for b in v] for k, v in t["drift"]["after"].items()}
    if t.get("waiting_on"):
        row["waiting_on"] = [ref(b, t["epic"], tree) for b in t["waiting_on"]]
    for key in ("unknown", "plan_checkpoint", "done_checkpoint"):
        if t.get(key):
            row[key] = t[key]
    return row


# ---------------------------------------------------------------- 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_config(project_root: Path | None) -> dict:
    """The `[tickets]` table of the project's store config, empty when there is none."""
    if not project_root:
        return {}
    cfg = project_root / "_bmad" / "custom" / "ticketing-store-config.toml"
    if not cfg.is_file():
        return {}
    tickets = tomllib.loads(read_text(cfg)).get("tickets", {})
    return tickets if isinstance(tickets, dict) else {}


def store_name(project_root: Path | None) -> str:
    store = store_config(project_root).get("store", "repo")
    return store if isinstance(store, str) and store else "repo"


def central_config(project_root: Path) -> dict:
    """The BMad config with its layers merged by the project's own `config_utils.py`."""
    path = project_root / "_bmad" / "scripts" / "config_utils.py"
    if not path.is_file():
        raise TicketError(f"cannot read the BMad config: {path} is missing")
    spec = importlib.util.spec_from_file_location("bmad_config_utils", path)
    module = importlib.util.module_from_spec(spec)
    spec.loader.exec_module(module)
    try:
        return module.load_central_config(project_root)
    except module.ConfigError as e:
        raise TicketError(str(e)) from e


def tickets_root(project_root: Path, config: dict | None = None) -> Path:
    """`{output_folder}` for the project: the ticket tree lives beside the documents."""
    config = central_config(project_root) if config is None else config
    core = config.get("core", {})
    output = str(core.get("output_folder", "") if isinstance(core, dict) else "")
    output = output.replace("{project-root}", str(project_root))
    return project_root / output


def active_initiative(project_root: Path) -> Path:
    """`{output_folder}/{active_initiative}` for the project."""
    config = central_config(project_root)
    core = config.get("core", {})
    name = core.get("active_initiative") if isinstance(core, dict) else None
    if not isinstance(name, str) or not name.strip():
        raise TicketError(
            "no active initiative: set core.active_initiative in _bmad/custom/config.user.toml, or pass a folder"
        )
    folder = (tickets_root(project_root, config) / name.strip()).resolve()
    if not folder.is_dir():
        raise TicketError(f"active initiative folder not found: {folder}")
    return folder


# ---------------------------------------------------------------- commands


def _folder(args) -> Path:
    if args.dir is None:
        root = project_root_for(args, Path.cwd())
        if root is None:
            raise TicketError("no project root found: no _bmad/ at or above the working directory; pass --project-root")
        # The store is then read from this project even when output_folder lies outside it.
        args.project_root = str(root)
        folder = active_initiative(root)
        backlog = (tickets_root(root) / "backlog").resolve()
        args.backlog = backlog if backlog.is_dir() and backlog != folder else None
        return folder
    folder = Path(args.dir).resolve()
    root = None if folder.is_dir() or Path(args.dir).is_absolute() else project_root_for(args, Path.cwd())
    if root is not None:
        try:
            bases = [tickets_root(root), root]
        except TicketError:  # no BMad config to name the store: the project root alone
            bases = [root]
        else:
            try:
                bases.insert(1, active_initiative(root))
            except TicketError:  # none set: the store and the project root
                pass
        for base in bases:
            if (base / args.dir).is_dir():
                return (base / args.dir).resolve()
    if not folder.is_dir():
        raise TicketError(f"not a folder: {folder}")
    return folder


def with_backlog(args, out: dict, view) -> dict:
    """Add the backlog's view to a command that ran on the active initiative."""
    backlog = getattr(args, "backlog", None)
    if backlog is not None:
        try:
            out["backlog"] = view(backlog)
        except (TicketError, OSError, ValueError) as e:
            out["backlog"] = {"folder": backlog.name, "error": str(e)}
        for group in ("ready_to_refine", "ready_to_start", "in_progress", "blocked", "tickets"):
            for row in out["backlog"].get(group, []):
                # An id here could also name a ticket in the initiative; the file name cannot.
                row["ref"] = row["file"] or row["ref"]
    return out


def locate(args, folder: Path, text: str) -> tuple[dict, dict]:
    """The ticket a reference names and the tree holding it: the folder's, else, with no `<dir>`, the backlog's."""
    tree = load_tree(folder)
    try:
        return resolve_ticket(tree, text), tree
    except NoMatch as miss:
        backlog = getattr(args, "backlog", None)
        if backlog is None:
            raise
        try:
            other = load_tree(backlog)
            other["fallback"] = True
            return resolve_ticket(other, text), other
        except (NoMatch, OSError, ValueError):  # not there, or a backlog that cannot be read
            raise miss from None


def next_view(folder: Path) -> dict:
    tree = load_tree(folder)
    declared = declared_after(tree)
    return {
        "folder": folder.name,
        **{k: [public(t, tree) for t in v] for k, v in classify(tree).items()},
        "unpinned_after": unpinned_after(tree, declared),
        **cross_epic_after(tree, declared),
        **({"problems": tree["problems"]} if tree["problems"] else {}),
    }


def cmd_next(args) -> dict:
    folder = _folder(args)
    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")
    return with_backlog(args, {"store": store, **next_view(folder)}, next_view)


def next_id(tree: dict, folder: str) -> int:
    """The id a new ticket in the folder takes: one past the highest number its entries and leaf files use, or
    `next_id` at the top of its `tickets.toml` when that is higher."""
    # `6a` uses up 6: a lettered id is a split of that number.
    used = [re.match(r"\d+", str(t["id"])) for t in tree["tickets"] if t["epic"] == folder]
    # An entry moved to another folder took its id along; the counter it left keeps that id from coming back.
    floor = load_breakdown(tree["folders"][folder]).get("next_id", 0)
    return max(max((int(m.group()) for m in used if m), default=0) + 1, floor)


def status_view(folder: Path) -> dict:
    tree = load_tree(folder)
    declared = declared_after(tree)
    tickets = in_scope(tree)
    counts = {}
    for t in tickets:
        counts[t["state"]] = counts.get(t["state"], 0) + 1
    blocks = {}
    for t in tree["tickets"]:
        for b in t["after"]:
            blocks.setdefault(b, []).append(t["key"])
    out = {
        "folder": folder.name,
        "tickets": [public(t, tree, blocks) for t in tickets],
        "counts": {"total": len(tickets), **counts},
        "longest_remaining_chain": longest_remaining_chain(tree),
        "unpinned_after": unpinned_after(tree, declared),
        **cross_epic_after(tree, declared),
        **({"problems": tree["problems"]} if tree["problems"] else {}),
    }
    if tree["scope"] is None:
        out["epics"] = [
            {
                "slug": slug,
                "id": tree["epic_ids"].get(slug),
                "status": c["status"],
                "after": declared.get(slug, []),
                "gated_by": c["after"],
                "blocks": [ref(b, None, tree) for b in blocks.get(slug, [])],
                "next_id": next_id(tree, slug),
            }
            for slug, c in tree["containers"].items()
        ]
    else:
        out["next_id"] = next_id(tree, tree["scope"])
    return out


def cmd_status(args) -> dict:
    folder = _folder(args)
    store = store_name(project_root_for(args, folder))
    return with_backlog(args, {"store": store, **status_view(folder)}, status_view)


PULLED = """---
{frontmatter}
---

# {heading}

## Description

{description}

## Acceptance Criteria

Verify: {verify}

## References

- parent — {parent}
{references}{notes}"""


def title_slug(title: str) -> str:
    title = unicodedata.normalize("NFKD", title).encode("ascii", "ignore").decode("ascii")
    return re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-")[:60].rstrip("-") or "untitled"


def resolve_ticket(tree: dict, text: str) -> dict:
    """The one row a reference names; see `<ref>` above."""
    tickets = tree["tickets"]
    ref = text.strip()
    if not ref:
        raise TicketError("the ticket reference is empty")
    low = ref.lower()
    hits = []
    m = CROSS_RE.match(ref)
    if m:
        slug = {i: s for s, i in tree["epic_ids"].items()}.get(_id(m.group(1)))
        hits = [t for t in tickets if slug and t["epic"] == slug and t["id"] == _id(m.group(2))]
    elif _id(ref) is not None and tree["scope"]:
        hits = [t for t in tickets if t["epic"] == tree["scope"] and t["id"] == _id(ref)]
    if not hits and isinstance(_id(ref), str):
        # An id with a letter names its ticket in any epic before the words of a title can match it.
        hits = [t for t in tickets if t["id"] == _id(ref)]
    for pool in (in_scope(tree), tickets):
        if not hits:
            hits = [t for t in pool if t["file"] and low in (t["file"].lower(), t["file"][:-3].lower())]
    if not hits:
        hits = [t for t in tickets if t["tracker_id"] and low == t["tracker_id"].lower()]
    if not hits and not ref.isdigit():
        hits = [t for t in in_scope(tree) if low in t["title"].lower()]
    if not hits:
        raise NoMatch(f"no ticket matches {ref!r}")
    if len(hits) > 1:
        names = ", ".join(ref_name(t, tree) for t in hits)
        raise TicketError(f"{ref!r} matches more than one ticket: {names}")
    return hits[0]


def leaf_stem(t: dict, tree: dict) -> str:
    """The leaf file's stem, or the one `pull` gives it: `<type>-<slug of the title>`, with `-<id>` added when a
    file or an earlier entry in the folder already has that name."""
    if t["file"]:
        return t["file"][:-3]
    stem = f"{t['type']}-{title_slug(t['title'])}"
    taken = set()
    for o in tree["tickets"]:
        if o is t:
            break
        if o["epic"] == t["epic"] and not o["file"]:
            taken.add(f"{o['type']}-{title_slug(o['title'])}")
    taken |= {o["file"][:-3] for o in tree["tickets"] if o["epic"] == t["epic"] and o["file"]}
    return f"{stem}-{t['id']}" if stem in taken else stem


def plan_path(t: dict, tree: dict) -> Path:
    """The joined plan, else `<leaf stem>-plan.md`."""
    folder = tree["folders"][t["epic"]]
    if t.get("plan"):
        return folder / t["plan"]
    return folder / f"{leaf_stem(t, tree)}-plan.md"


def cmd_find(args) -> dict:
    folder = _folder(args)
    t, tree = locate(args, folder, args.ref)
    home = tree["folders"][t["epic"]]
    row = public(t, tree)
    if tree.get("fallback") and t["file"]:
        row["ref"] = t["file"]  # as the backlog view names it: an id could name a ticket in the initiative
    # Once a leaf file exists it holds the text and the entry's copy is no longer kept up. Unlisted and stray
    # leaves have no entry.
    entry = {} if t["file"] else t
    container = home / f"{home.name}.md"  # an epic's file, or the initiative's for a leaf directly under one
    return {
        **row,
        "folder": home.name,
        "description": entry.get("description", ""),
        "verify": entry.get("verify", ""),
        "references": entry.get("references", []),
        "notes": entry.get("notes", []),
        "unknown": t.get("unknown", ""),
        "epic_file": str(container) if container.is_file() else None,
        "story_file": str(home / t["file"]) if t["file"] else None,
        "plan": str(plan_path(t, tree)),
    }


def ref_name(t: dict, tree: dict) -> str:
    return f"{t['file'] or t['id']} in {t['epic']}"


def cmd_pull(args) -> dict:
    folder = _folder(args)
    tree = load_tree(folder)
    n = _id(args.id)
    t = next((t for t in in_scope(tree) if t["epic"] == folder.name and t["id"] == n), None)
    if n is None or t is None:
        raise TicketError(f"{folder.name}/{BREAKDOWN} has no entry {args.id}")
    if t["file"]:
        raise TicketError(f"entry {args.id} is already pulled: {t['file']}")
    path = write_leaf(t, tree, project_root_for(args, folder))
    return {"file": path.name, "refine": t["refine"]}


def write_leaf(t: dict, tree: dict, root: Path | None) -> Path:
    """Write an entry's leaf file from the entry and name it on the row."""
    folder = tree["folders"][t["epic"]]
    path = folder / f"{leaf_stem(t, tree)}.md"
    if path.exists():
        raise TicketError(f"{path.name} exists already; change entry {t['id']}'s title")
    after = [str(ref(b, t["epic"], tree)) for b in t["after"]]
    epic_file = folder / f"{folder.name}.md"
    try:
        parent = Path(os.path.relpath(epic_file, root)).as_posix() if root else epic_file.as_posix()
    except ValueError:  # another drive on Windows
        parent = epic_file.as_posix()
    notes = ([f"Unknown: {t['unknown']}"] if t["unknown"] else []) + t["notes"]
    # Other empty fields are left out; `after` and `hitl` stay because `status` compares them with the entry.
    # No status: the build writes it when it starts.
    fields = [
        ("id", str(t["id"])),
        ("type", t["type"]),
        ("title", json.dumps(t["title"], ensure_ascii=False)),
        ("parent", t["epic"]),
        ("covers", f"[{', '.join(t['covers'])}]" if t["covers"] else ""),
        ("after", f"[{', '.join(after)}]"),
        ("refined", "false" if t["refine"] else ""),
        ("hitl", "true" if t["hitl"] else "false"),
        ("risk", t["risk"]),
        ("estimate", json.dumps(str(t["estimate"])) if t["estimate"] != "" else ""),
    ]
    path.write_text(
        PULLED.format(
            frontmatter="\n".join(f"{k}: {v}" for k, v in fields if v != ""),
            heading=t["title"],
            parent=parent,
            description=t["description"],
            verify=t["verify"],
            references="".join(f"- {r}\n" for r in t["references"]),
            notes="\n## Notes\n\n" + "".join(f"- {n}\n" for n in notes) if notes else "",
        ),
        encoding="utf-8",
    )
    t["file"] = path.name
    return path


def quoted(value: str) -> str:
    """A double-quoted scalar that `parse_frontmatter` reads back exactly."""
    # parse_frontmatter cuts a value at "   #" and splits lines on these characters, so they go in as escapes.
    text = json.dumps(value, ensure_ascii=False).replace("   #", "   \\u0023")
    return text.translate({c: f"\\u{c:04x}" for c in (0x85, 0x2028, 0x2029)})


def edit_frontmatter(path: Path, values: dict[str, str]) -> str:
    """Set frontmatter values in a file, keeping its line endings and byte-order mark; an empty value removes
    the line. Returns the new text."""
    raw = path.read_bytes()
    text = raw.decode("utf-8-sig").replace("\r\n", "\n")
    for key, value in values.items():
        text = set_frontmatter_value(text, key, value)
    data = text.encode("utf-8")  # before the file is opened, so a failure leaves the file as it was
    if b"\r\n" in raw:
        data = data.replace(b"\n", b"\r\n")
    if raw.startswith(codecs.BOM_UTF8):
        data = codecs.BOM_UTF8 + data
    path.write_bytes(data)
    return text


def cmd_mark(args) -> dict:
    """Write status, assignee, and the blocked fields to the ticket's plan; never to its leaf file."""
    folder = _folder(args)
    store = store_name(project_root_for(args, folder))
    if store != "repo":
        raise StoreRefusal(f"store is {store}: change status through the store's write verb, not this script")
    t, tree = locate(args, folder, args.ref)
    path = plan_path(t, tree)
    blocked = {"blocked_at": "", "blocked_reason": ""}
    if args.blocked is not None:
        blocked = {"blocked_at": quoted(date.today().isoformat()), "blocked_reason": quoted(args.blocked)}
    created = not t.get("plan")
    if created:
        assignee = args.assignee if args.assignee is not None else t["assignee"]
        fields = [
            ("title", quoted(t["title"])),
            ("ticket", str(t["id"]) if t["id"] is not None else quoted(t["file"][:-3])),
            ("status", args.status),
            ("assignee", quoted(assignee) if assignee else ""),
            *blocked.items(),
        ]
        text = "---\n" + "".join(f"{k}: {v}\n" for k, v in fields if v != "") + "---\n"
        data = text.encode("utf-8")  # before the file is opened, so a failure leaves no empty plan
        try:
            with path.open("xb") as f:
                f.write(data)
        except FileExistsError:
            raise TicketError(f"{path.name} exists already and is not the plan for {ref_name(t, tree)}") from None
    else:
        values = {"status": args.status, **blocked}
        if args.assignee is not None:
            values["assignee"] = quoted(args.assignee)
        text = edit_frontmatter(path, values)
    fm = parse_frontmatter(text, lenient=True)
    return {
        "plan": str(path),
        "created": created,
        **{k: fm.get(k, "") for k in PLAN_FIELDS},
    }


MIRROR_KEYS = ("ref", "tracker_id", "remote", "tracker_status", "assignee", "after")


def _mirror_values(item: dict, where: str) -> dict[str, str]:
    """The frontmatter lines one mirrored ticket sets, each validated."""
    values = {}
    for key in ("tracker_id", "remote", "assignee"):
        if key in item:
            value = item[key]
            if key == "tracker_id" and isinstance(value, int) and not isinstance(value, bool):
                value = str(value)  # a tracker that numbers its items returns a number
            if not isinstance(value, str):
                raise TicketError(f"{where}: {key} must be a string")
            values[key] = quoted(value) if value else ""
    if "tracker_status" in item:
        values["tracker_status"] = _one_of(item["tracker_status"], STATES, where, "tracker_status")
    if "after" in item:
        if not isinstance(item["after"], list):
            raise TicketError(f"{where}: after must be a list")
        parts = []
        for b in item["after"]:
            if isinstance(b, bool) or not isinstance(b, (int, str)) or (isinstance(b, str) and ("," in b or not b)):
                raise TicketError(f"{where}: after takes ids and names without commas, not {b!r}")
            # A number stays bare, a sibling's id; a string is quoted, so a tracker id of digits stays one.
            parts.append(str(b) if isinstance(b, int) else quoted(b))
        values["after"] = f"[{', '.join(parts)}]"
    return values


def by_tracker_id(tree: dict, tracker_id: str) -> dict:
    """The one ticket that already carries a tracker id; nothing else about it is matched."""
    hits = [t for t in tree["tickets"] if t["tracker_id"] and t["tracker_id"].lower() == tracker_id.lower()]
    if not hits:
        raise NoMatch(f"no ticket carries tracker_id {tracker_id!r}")
    if len(hits) > 1:
        raise TicketError(f"tracker_id {tracker_id!r} is on more than one ticket")
    return hits[0]


def cmd_mirror(args) -> dict:
    """Write what the tracker returned into each ticket's leaf file; never `status`, which is the build's."""
    folder = _folder(args)
    root = project_root_for(args, folder)
    store = store_name(root)
    if store == "repo":
        raise StoreRefusal("store is repo: there is no tracker to mirror")
    items = json.load(sys.stdin)
    if not isinstance(items, list) or not all(isinstance(i, dict) for i in items):
        raise TicketError("mirror reads a JSON array of objects on stdin")
    folders, trees = [folder], [load_tree(folder)]
    if getattr(args, "backlog", None):
        try:
            trees.append(load_tree(args.backlog))
            folders.append(args.backlog)
        except (TicketError, OSError, ValueError):  # a backlog that cannot be read holds no match
            pass
    work, unmatched, seen = [], [], set()
    for n, item in enumerate(items, 1):
        name = item.get("ref", item.get("tracker_id"))
        where = f"item {n} ({name!r})"
        unknown = [k for k in item if k not in MIRROR_KEYS]
        if unknown:
            hint = "; status is the build's and is never mirrored" if "status" in unknown else ""
            raise TicketError(f"{where}: unknown key {unknown[0]!r}{hint}")
        if isinstance(name, bool) or not isinstance(name, (int, str)) or name == "":
            raise TicketError(f"item {n}: give a ref, or the tracker_id of a ticket that already carries it")
        values = _mirror_values(item, where)
        hit = miss = None
        for tree in trees:
            try:
                # A tracker id alone never falls back to an entry id or a title that happens to match it.
                t = resolve_ticket(tree, str(name)) if "ref" in item else by_tracker_id(tree, str(name))
            except NoMatch as e:
                miss = miss or e
                continue
            hit = (t, tree)
            break
        if hit is None:
            unmatched.append({"ref": name, "error": str(miss)})
            continue
        if id(hit[0]) in seen:
            raise TicketError(f"{where}: {ref_name(*hit)} is named twice")
        seen.add(id(hit[0]))
        work.append((*hit, values))
    undo, mirrored = [], []
    try:
        for t, tree, values in work:
            pulled = not t["file"]
            if pulled:
                path = write_leaf(t, tree, root)
                undo.append((path, None))
            else:
                path = tree["folders"][t["epic"]] / t["file"]
                undo.append((path, path.read_bytes()))
            edit_frontmatter(path, values)
            ref_out = row_ref(t, tree) if tree is trees[0] else path.name
            mirrored.append({"ref": ref_out, "file": path.name, "pulled": pulled, "set": sorted(values)})
        for f in folders:
            load_tree(f)
    except (TicketError, OSError, ValueError) as e:
        for path, raw in undo:
            try:
                if raw is None:
                    path.unlink(missing_ok=True)
                else:
                    path.write_bytes(raw)
            except OSError:  # keep restoring the rest; the first failure is the one reported
                pass
        # The discovery procedure needs `unmatched` even when a known ticket's `after` named one of them.
        raise TicketError(f"nothing was mirrored: {e}", **({"unmatched": unmatched} if unmatched else {})) from e
    return {"folder": folder.name, "store": store, "mirrored": mirrored, "unmatched": unmatched}


REF_HELP = """<ref> is `<epic id>.<entry id>`, an entry id inside an epic folder (one with a letter, from any
folder), a tracker id, a file name, or an unbroken phrase from the title that matches one ticket."""

DIR_HELP = """<dir> is an epic folder, a backlog folder, or an initiative folder (all its epics): a path, or a
folder's name under the store or the active initiative. Left out, it is the active initiative."""

BACKLOG_HELP = "With no <dir>, a ticket the initiative does not hold is looked for in the backlog folder."

HELP = {
    "next": f"""Tickets grouped by what can happen next, in build order.

{DIR_HELP} With no <dir>, `backlog` holds the same view of the backlog folder, each row's `ref`
its file name.

Output: `ready_to_refine` (needs full criteria first), `ready_to_start`, `in_progress`, and `blocked`; a
`blocked` row has `waiting_on`, its unmet prerequisites, `blocked_reason`, or `unknown`, a question to
settle before it starts. A prerequisite is met when it is done or in review; an epic's own gate
(`gated_by`) waits for that epic to be done. `unpinned_after`, `undeclared_after`, `order_conflict`, and
`problems` report a tree that needs fixing. Every row carries `ref`, which find resolves, `state`, and
`risk`; a row has `drift` when the leaf file's `after` or `hitl` differs from the entry's, and `plan_checkpoint` or
`done_checkpoint` when the entry sets it.""",
    "status": f"""Every ticket in build order.

{DIR_HELP} With no <dir>, `backlog` holds the same view of the backlog folder, each row's `ref`
its file name.

Output: `tickets` (each with `status`, `tracker_status`, `state`, `blocks`, and `drift` when the leaf file's
`after` or `hitl` differs from the entry's, with both values), `counts` by state, `next_id` (the id a new
ticket in the folder takes: one past the highest number used, or `next_id` at the top of the folder's
`tickets.toml` when that is higher; on an initiative, in each `epics` row), `longest_remaining_chain`, and
on an initiative `epics` with each epic's declared `after`.""",
    "find": f"""The one ticket a reference names.

{DIR_HELP}
{BACKLOG_HELP}
{REF_HELP}

Output: the ticket's row; its entry's `description`, `verify`, `references`, and `notes`, all empty once
the entry is pulled, when `story_file` holds them; `unknown`; its `folder`; and the absolute paths
`epic_file` (the file of the container it is under, null in a backlog folder), `story_file` (null until
the entry is pulled), and `plan` (where its plan is or goes; it may not exist yet).""",
    "pull": """Write an entry's leaf file from its entry.

<dir> is the epic folder and <id> the entry's id. The file is `<type>-<slug of the title>.md`: `after` and
`hitl` always, other fields only when the entry sets them, no status.""",
    "mark": f"""Set a ticket's status in its plan; never in its leaf file. Repo store only.

{DIR_HELP}
{BACKLOG_HELP}
{REF_HELP}

A ticket with no plan gets one holding only frontmatter. `--blocked` sets `blocked_at` (today) and
`blocked_reason`; without it both are cleared.""",
    "mirror": f"""Write what a tracker returned into leaf files. Tracker stores only.

{DIR_HELP}
{BACKLOG_HELP}

Stdin is a JSON array, one object per ticket: `ref` (or `tracker_id` alone, which matches only a ticket
that already carries that id) and any of `tracker_id`, `remote`, `tracker_status` ({", ".join(STATES)}),
`assignee`, and `after` (a list: a number is a sibling's id, a string any other prerequisite form, a
tracker id included). Only the keys given are written, an empty string removes the line, and `status` is
never written. An entry with no leaf file is pulled first. A ticket the tree does not hold is listed under
`unmatched` and the rest are written; values that would leave the tree unreadable write nothing, and the
error then still lists `unmatched`.""",
}


def main() -> int:
    raw = argparse.RawDescriptionHelpFormatter
    parser = argparse.ArgumentParser(description=__doc__, formatter_class=raw)
    parser.add_argument(
        "--project-root",
        help="project holding _bmad/; default: walk up from the ticket folder, or the working directory with no folder",
    )
    sub = parser.add_subparsers(dest="command", required=True)
    p = sub.add_parser(
        "next",
        help="tickets whose prerequisites are done or in review, by state",
        description=HELP["next"],
        formatter_class=raw,
    )
    p.add_argument("dir", nargs="?", help="default: the active initiative")
    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", description=HELP["status"], formatter_class=raw)
    p.add_argument("dir", nargs="?", help="default: the active initiative")
    p.set_defaults(func=cmd_status)
    p = sub.add_parser("find", help="the one ticket a reference names", description=HELP["find"], formatter_class=raw)
    p.add_argument("dir", nargs="?", help="default: the active initiative")
    p.add_argument("ref")
    p.set_defaults(func=cmd_find)
    p = sub.add_parser("pull", help="write an entry's leaf file", description=HELP["pull"], formatter_class=raw)
    p.add_argument("dir")
    p.add_argument("id")
    p.set_defaults(func=cmd_pull)
    p = sub.add_parser(
        "mark",
        help="set a ticket's status in its plan (repo store only)",
        description=HELP["mark"],
        formatter_class=raw,
    )
    p.add_argument("dir", nargs="?", help="default: the active initiative")
    p.add_argument("ref")
    p.add_argument("status", choices=STATUSES)
    p.add_argument("--assignee")
    p.add_argument(
        "--blocked", metavar="REASON", help="set blocked_at to today and blocked_reason; else both are cleared"
    )
    p.set_defaults(func=cmd_mark)
    p = sub.add_parser(
        "mirror",
        help="write what a tracker returned into leaf files (tracker stores only)",
        description=HELP["mirror"],
        formatter_class=raw,
    )
    p.add_argument("dir", nargs="?", help="default: the active initiative")
    p.set_defaults(func=cmd_mirror)
    args = parser.parse_args()
    try:
        print(json.dumps(args.func(args), ensure_ascii=False, default=str))
        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), **getattr(e, "data", {})}, ensure_ascii=False), file=sys.stderr)
        return 1


if __name__ == "__main__":
    if sys.platform == "win32":
        # Piped output on Windows defaults to a legacy code page, not UTF-8.
        sys.stdout.reconfigure(encoding="utf-8")
        sys.stderr.reconfigure(encoding="utf-8")
    raise SystemExit(main())
SKILL.md›
---
name: bmad-ticket
description: Create and manage tickets — initiatives, epics, stories, bugs. Use when the user says "create a new initiative", "slice this initiative", "split this epic up", "break this into stories", "incept this epic", "make a ticket", "refine this ticket", "what's ready", "status of a story", "publish ticket changes", "validate these tickets", "set up the ticket store".
---

# 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 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. The check on a draft in `references/validate.md` always runs. Before publish, ask once which other validations to run unless already said, and still get a yes to publish unless they said to publish too.

## Terms

- Container: an initiative or an epic — holds other tickets
- Envelope: a container's file before its children are planned — intent, Outcome, Done when, boundaries, known decisions, references
- 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
- Breakdown: `tickets.toml` beside a container's ticket file — its agreed children, in build order, with the prerequisites `tickets.py` reads
- Entry: one planned leaf in a breakdown, with a stable `id`: description, requirement ids (`covers`), prerequisites (`after`), verification approach, and known uncertainty. It needs no file to be built: the build reads the entry and its epic. With no file and no plan its state is `planned`
- Pull: write an entry's leaf file with `tickets.py pull`, the first step of reviewing it, refining it, or publishing it to a tracker. Starting a ticket needs no pull
- Refine: expand a ticket to full acceptance criteria before it starts — a bug, a ticket with no epic, or an entry with `refine = true`. Any other story goes to the build as it is; the user can ask to review one first in its pulled file
- Inception: plan the whole selected epic with the user and record it in the epic's breakdown
- 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
- State: `planned`, `backlog`, `in-progress`, `review`, `done`, or `dropped` — what the board groups by and what a tracker sees. Every `tickets.py` row carries its ticket's `state`; use it rather than deriving it

## On activation

1. Resolve config: `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root} --key core.output_folder --key core.active_initiative`.
   - Script not found: BMad is not set up here. Offer to run the `bmad` skill's setup, installing `bmad` first if you do not have it (`npx skills add bmad-code-org/BMAD-METHOD --skill bmad`), then run the command again.

   Tickets are drafted under `{output_folder}/{active_initiative}/` — 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 `[core]` in `{project-root}/_bmad/custom/config.user.toml`.
2. Read the store config: `uv run {skill-root}/scripts/read_store.py --project-root {project-root} -k tickets` — the store, how to reach it, and its type and status maps. A project that has set up no store uses the repo store, and this still answers.
3. Resolve the customization: `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --project-root {project-root} -k workflow`. A `{workflow.<key>}` in a reference is that key's value in this result.
4. Run `{workflow.activation_steps_prepend}`. Treat `{workflow.persistent_facts}` as foundational context for the session: entries prefixed `file:` are paths or globs under `{project-root}` to load, the rest are facts verbatim.
5. Run `{workflow.activation_steps_append}`. When the requested operation ends, run `{workflow.on_complete}`.

## Intake

For a request that creates work, size it 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: `references/ticket.md`; a file in `backlog/`, or an entry in the epic the user names; no spec offered, the single-ticket check.
- **Epic** — more than one ticket, all of which one epic holds: the epic path in `references/slice.md`.
- **Initiative** — work that needs more than one epic: authored and split into epics per `references/slice.md`.

A spec folder handed over by `bmad-spec` is the epic's requirement source: `covers` cites its `CAP-N` ids and no spec is offered.

## Routing

| The user wants | Read |
|---|---|
| an initiative started or authored, split into epics; an epic incepted into stories or re-sliced | `{skill-root}/references/slice.md` |
| one ticket written, reviewed, or refined | `{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` |
| a tracker connected; the store reconfigured or switched | `{skill-root}/references/store-setup.md` |

`tickets.py` in this skill is `uv run {skill-root}/scripts/tickets.py --project-root {project-root}`. On the repo store, for "what is ready" or "what is the status", run `tickets.py next` or `tickets.py status` first and answer from it: it covers the active initiative and `backlog/`. Open `references/board.md` to act on a row, or when `problems`, `drift`, `unpinned_after`, `undeclared_after`, or `order_conflict` comes back with something in it. On a tracker, open `references/board.md` first.

## Store operations

`uv run {skill-root}/scripts/read_store.py --project-root {project-root} -k verbs.<name>` (repeat `-k`), then follow the verb as written. A verb holds the store's own commands; the rules every store shares are in `references/board.md`.

| Verb | For |
|---|---|
| `setup` | connect the tool; create what the maps name |
| `write` | create or change a ticket — body, state, assignee, parent, blocking, fields |
| `query` | one ticket, a container's children, a search |

A store call that fails: tell the user what is blocking, confirm the setup with them, and ask them to authenticate when that is the cause. 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

```
{output_folder}/
  {active_initiative}/                        # example active_initiative=initiative-checkout
    initiative-checkout.md
    tickets.toml                             # the epics in build order
    spec-checkout/
    epic-cart-rules/
      epic-cart-rules.md
      tickets.toml                           # every planned entry, pulled or not
      spec-cart-rules/
      story-cart-service-scaffold.md           # pulled; its id is in its frontmatter
      story-cart-service-scaffold-plan.md      # the build's plan, with the ticket's status
      story-apply-discount-codes-plan.md       # a plan for an entry with no file
      spike-discount-engine-latency.md
  backlog/
    bug-checkout-total-ignores-discount-codes.md
```

- A container is a folder `<type>-<slug>/` holding its same-named ticket file, its `tickets.toml`, its spec, and its children. When an initiative has epics, every leaf is under one.
- A leaf under a container is an entry in that `tickets.toml`; it gets a file `<type>-<slug>.md` beside it only when pulled. A leaf with no parent is a file in `backlog/`. Other skills' artifacts sit beside the ticket, named after it.
- An entry's `id` is a number (or letters and digits, such as `6a`, when the work already has that id), assigned once and never reused; a ticket added to a folder takes `next_id` from `tickets.py status <folder>`. It is the ticket's only name on the repo store: `3` inside its epic, `2.3` from another epic. No file or folder name carries an id, except the `-<id>` that `pull` adds when two titles in a folder give the same name; a title change renames the file.
- The order of tables in `tickets.toml` is the build order; `id` is not.
- Once a leaf file exists it is truth: edit the file, and keep the entry's `id`, `type`, `title`, `after`, and `hitl` equal to it. The entry's other fields are not maintained after pull.
- A leaf's `status` and blocked fields live in the build's plan, `<type>-<slug>-plan.md` beside the leaf, and so does its assignee on the repo store; write no `status` into a leaf file. `bmad-ticket` changes them only as `references/board.md` says, and the plan is never sent to a tracker.

By default the tree is the store. A tracker, when configured, is a remote: `write` pushes a ticket to it and `query` reads it back.

Resolve a ticket the user names — `1.2`, a tracker id, a file name, an unbroken phrase from its title — with `tickets.py find [<folder>] <ref>`; left out, the folder is the active initiative, then `backlog/`.

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.