Files
2026-10-15 08:57:23 +02:00

12 KiB

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.