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, readstatusand pick the ticket yourself, asking when more than one fits. In the result,story_fileis null until the entry is pulled, andplanis 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 itsstatus,tracker_status, andstate, 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:nextandstatusaddbacklog, the same view ofbacklog/, so show both, andfind,mark, andmirrorlook inbacklog/for a ticket the initiative does not hold. A done standalone ticket stays inbacklog/beside its plan.- With a tracker, bring the tree up to date first (Tracker sync, below) and pass
--syncedtonext.
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 pullwhen it has none, because the file is the body the tracker receives: the whole file without the frontmatter linestracker_id,remote,tracker_status,status, andassignee. - Create parents before children, and a prerequisite before what waits on it. A prerequisite that is still local joins the proposed publication scope. An
afternaming 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
fieldsname — a label, list, status, or property — offersetup. - Then mirror what the tracker returned (Tracker sync).
mirrorwrites leaves only: a published container'stracker_idandremotego 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 thewriteverb describes.marktakes its folder and ref asfinddoes, 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.
writetransitions the item to[tickets.status].<state>, the ticket'sstate, and sets its assignee; it never sendsstatusas a word. Then mirror the item (Tracker sync): its status comes back astracker_statusand its assignee lands in the leaf file.markrefuses a tracker store. - Waiting on a person or an answer, not on a prerequisite, on the repo store:
markwith the ticket's current status,draftwhen it has none, and--blocked <reason>setsblocked_at(today) andblocked_reason, andnextlists the ticket underblocked; amarkwithout it clears both when the ticket moves. - A container's
statusisbmad-ticket's, an edit to its file: absent until the first ticket under it starts, thenin-progress, anddoneordroppedwhen the user confirms it; containers never take review. A published container gets the same transition throughwrite.
Close, drop, move
- Closing every child does not close the parent. Run the closure check in
validate.mdagainst 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 todroppedas Status says; an entry never published to the tracker takestracker_status: droppedthroughmirror. An entry is never deleted from an approvedtickets.toml, so its id is never reused. A dropped ticket still blocks what waits on it, in any epic:statuslists them underblocks; remove or repoint it in each one'safterwith 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(fromtickets.py status <folder>) at the top of itstickets.tomlasnext_id = <n>, so the id that leaves is not given out again. Then move its entry from the oldtickets.tomlto its place in the new one's build order, with that folder'snext_id(fromtickets.py status <folder>) as its id; move the leaf file and its plan into the new folder; set the file'sidandparentand the plan'sticketto the new id. Then rewrite both directions ofafter: its own, where a sibling it left is now<epic id>.<entry id>, and everyafterthat named it. Move itscoversids between the two epics when the requirement moves with it. An epic moves as its whole folder, with its[[epic]]table. Then runtickets.py statuson 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'safternames it: add its[[entry]]to the parent'stickets.tomlwith the folder'snext_idand the item's type, title, and a one-sentence description, mirror it again withrefnaming the new entry, then put the item's body in the pulled file. - A published container is queried with its leaves, and
mirrordoes 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_folderis in a git repo; never push.