Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ Things that break silently. Never violate; when in doubt, read the named module'

- No LLM calls in the orchestrator control loop.
- Sessions complete only on hook Stop events or window death — never on LLM prose. Never add another completion path. Post-session state is re-verified deterministically (`verify.py`).
- `sprintstatus.advance()` is the orchestrator's sole write path to sprint-status.yaml (internally serialized cross-process; the dev skill flips only spec frontmatter; the engine mirrors it onto the board pre-verify). Legal phase transitions live only in `statemachine.py`. `TicketTree.mark` is the orchestrator's sole write path to a ticket plan's `status`/`blocked_*` in tickets mode, and `TicketTree.restore_preimage` the sole byte-exact restore of a pre-image captured by the same transaction; legal plan transitions live in `statemachine.py`; the build skill writes plan status itself and bmad-loop never marks while a session for that ticket is live.
- `sprintstatus.advance()` is the orchestrator's sole write path to sprint-status.yaml (internally serialized cross-process; the dev skill flips only spec frontmatter; the engine mirrors it onto the board pre-verify). Legal phase transitions live only in `statemachine.py`. `TicketTree.mark` is the orchestrator's sole write path to a ticket plan's `status`/`blocked_*` in tickets mode, and `TicketTree.restore_preimage` the sole byte-exact restore of a pre-image captured by the same transaction; legal plan transitions live in `statemachine.py`; the build skill writes plan status itself and bmad-loop never marks while a session for that ticket is live. `TicketTree.mark_initiative_done` is the sole write path to a child initiative file's `status` (transition in `statemachine.py`): only for a drained child of a chained run, never while a session for that child is live.
- All git subprocess calls in `src/bmad_loop` go through the `_run_git` chokepoint in `verify.py` (timeouts, `LC_ALL=C`) — no bare subprocess git; `tests/test_portability_guard.py` enforces it. Tests, `scripts/`, and CI workflows deliberately spawn their own git: a harness must not depend on the artifact it validates.
- Every new policy field needs an entry in `src/bmad_loop/data/settings/core.toml` (a sync test enforces defaults/options match `policy.py`). New core env vars register in `envvars.py`; plugin-owned env-var families stay with their plugin.
- Version strings are stamped only by `scripts/sync_version.py` from `src/bmad_loop/__init__.py` — never hand-edit pyproject.toml, module.yaml, marketplace.json, bmod.toml, or uv.lock versions.
Expand Down
2 changes: 1 addition & 1 deletion docs/FEATURES.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,7 @@ bmad-loop drives a BMAD sprint unattended: a deterministic Python loop launches
- **Description:** An opt-in third story source for BMAD v7 projects. It drives the same pipeline from the active initiative's ticket tree through the project's own `tickets.py`, dispatching each ticket as `/<dev skill> ticket <ref>` in initiative build order, and, under the default `mark_done`, marks a verified ticket `done` itself.
- **Properties:**
- A run is pinned to `[core] active_initiative` at start; a changed initiative pauses the run at `ticket-tree` instead of building another initiative's ticket.
- The tree is read in the layout the installed `tickets.py` uses: a `tickets/` folder inside each initiative and epic, or the preview-era flat layout. A parent initiative is refused (`tickets.initiative-parent`), and an epic declared but not sliced yet holds no tickets.
- The tree is read in the layout the installed `tickets.py` uses: a `tickets/` folder inside each initiative and epic, or the preview-era flat layout. A parent initiative passes the preflight only under worktree isolation without `gates.retrospective = "auto"`, with the store checks run for each sliced child (`tickets.initiative-parent`), and an epic declared but not sliced yet holds no tickets.
- A hitl ticket pauses at `ticket-hitl` and a ticket that needs refining at `ticket-refine`, both before any session runs for it.
- bmad-loop writes a plan's `status` and `blocked_*` only through `tickets.py mark`, under a lock, read back afterwards and checked against one plan transition table; it never writes `dropped` or an epic file.
- Tickets this run did not start are never dispatched or marked.
Expand Down
6 changes: 3 additions & 3 deletions docs/reference/cli.md
Original file line number Diff line number Diff line change
Expand Up @@ -557,7 +557,7 @@ Each task: `story_key`, `epic`, `phase`, `attempt`, `review_cycle`, `tokens` (`i

`epic` is always an integer. A unit of a source with no integer epics (stories, sweep, tickets) reports `0`; a ticket's epic is in its `ticket` object.

`tickets` (run level): `initiative` and `initiative_folder` (the pinned initiative), `epic_filter` (the `--epic` slug, or null), `store_topology` (`{layout, placement, versioning, store_root, store_repo}`, or null when none was recorded), `epic_closures` (epic slug → recorded closure verdict) and `epic_closures_acked` (epic slugs whose closure pause was resumed past).
`tickets` (run level): `initiative` and `initiative_folder` (the pinned initiative), `parent_initiative` (the parent initiative whose child the run builds, or null), `epic_filter` (the `--epic` slug, or null), `store_topology` (`{layout, placement, versioning, store_root, store_repo}`, or null when none was recorded), `epic_closures` (epic slug → recorded closure verdict) and `epic_closures_acked` (epic slugs whose closure pause was resumed past).

`ticket` (per task): `ref`, `id`, `epic_slug`, `epic_id`, `title`, `plan` (the absolute plan path recorded at the pick), `done_pending` (bool: a shared-store `done` mark is still owed), `park_pending` (the owed park's reason, or null) and `commit_pending` (bool: under `mark_done = "never"`, the store-repository commit of the `built` plan is still owed). It is null for a task with no ticket. A plan's live status is not part of the document, because it is not in `state.json`; the text board reads it.

Expand Down Expand Up @@ -712,11 +712,11 @@ Every finding's `check` is one of these ids. Severity is `ok`, `warning` or `pro
| `tickets.uv-missing` | problem | tickets mode: `uv` is not on PATH (`bmad-build-auto` runs `tickets.py` with `uv run`) |
| `tickets.initiative-unset` | problem | tickets mode: no `[core] active_initiative` is set |
| `tickets.initiative-missing` | problem | tickets mode: the active initiative's folder does not exist |
| `tickets.initiative-parent` | problem | tickets mode: the active initiative is a parent initiative (its `tickets.toml` lists `[[initiative]]` children); set one child active |
| `tickets.initiative-parent` | ok / problem | tickets mode: the active initiative is a parent initiative; a problem unless worktree isolation runs without the auto retrospective |
| `tickets.store-not-repo` | problem | tickets mode: the ticket store is a tracker, or its config cannot be read; bmad-loop drives repo stores only |
| `tickets.store-topology` | ok / problem | tickets mode: the classified ticket-store layout; a problem for a store git cannot classify, or one tracked outside `repo_root` (worktree: the project) |
| `tickets.tree-untracked` | problem | tickets mode: the initiative folder is only partly tracked, or neither tracked nor gitignored inside the code checkout |
| `tickets.own-repo-isolation` | problem | tickets mode, worktree isolation: a shared store needs the mount redirect, but `_bmad/custom/config.user.toml` is tracked or not gitignored |
| `tickets.own-repo-isolation` | problem | tickets mode, worktree isolation: the mount redirect (shared store, or a parent's child) needs `_bmad/custom/config.user.toml` untracked and ignored |
| `tickets.workspace-unsupported` | problem | tickets mode: the project is not a git repository (a BMAD workspace); not supported yet |
| `tickets.store-sandbox` | warning | tickets mode: sessions write a shared store outside their cwd while a role's `extra_args` replace the profile's bypass flags or a role runs sandboxed |
| `tickets.branch-per-run` | warning | tickets mode: `[scm] branch_per = "run"` builds every ticket on one branch, which build-auto's branch-fit check may HALT |
Expand Down
2 changes: 2 additions & 0 deletions docs/reference/run-state.md
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,8 @@ A run paused on a usage limit also records `paused_limit_class` (`blocked` for a

A tickets run's `state.json` also records `initiative` and `initiative_folder`, the BMAD v7 initiative the run is pinned to: every ticket-tree read refuses while the live `[core] active_initiative` names another. Each task carries a `ticket` binding resolved when it was picked (ref, id, epic slug and id, title, plan, risk, and the refined, hitl and checkpoint flags). `ticket_epic_filter`, `ticket_story_approvals`, `ticket_epic_closures`, `ticket_epic_closure_resumed` and `ticket_epic_closure_acked` hold the epic selector, the refs whose story file an operator approved (resuming the pick's `plan-checkpoint` pause records the ref), each finished epic's closure (its retrospective `verdict`, or `unknown`), the epics whose closure pause a resume answered (a closure recorded without one pauses again), and the epics whose one closure pause was resumed past. Tickets pauses add the `paused_stage` values `ticket-hitl`, `ticket-refine` and `ticket-tree` (causes below). `run` resolves a tickets run's selectors before it starts: `ticket_epic_filter` holds the `--epic` epic slug, `story_filter` the `--story` ticket ref, and `epic_filter` stays null.

`parent_initiative` names the parent initiative when a tickets run builds one of its children, and is null otherwise.

A v7 sweep records `initiative` and `initiative_folder` too, and its resume, `resolve` and the dashboard's re-arm refuse while the live `[core] active_initiative` names another, because its ledger and pre-answers belong to the initiative it started on.

A pick pause, and a resume's stale-binding pause (`binding-moved`: an in-flight unit's ref no longer names its plan, or its verified plan is gone), journal `ticket-pick-halt` with its `cause` and `stage`:
Expand Down
6 changes: 3 additions & 3 deletions docs/tickets-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ Tickets mode is **experimental**. It is built and tested against BMAD-METHOD mai
1. **Install BMAD v7 and bmad-loop.** Follow [Installing on BMAD v7](setup-guide.md#installing-on-bmad-v7-bmad-setup): `npx skills add bmad-code-org/bmad-loop`, then `/bmad setup bmad-loop`, then `/bmad-loop-setup`. The module record requires `bmad` and `bmad-build-auto` at 6.13.0 or newer and recommends `bmad-agent-po` and `bmad-retrospective`, which tickets mode uses.
2. **Plan the tree with Sofia.** Talk to Sofia, the Product Owner agent (`bmad-agent-po`): she creates the initiative, slices it into epics and stories, writes and refines the tickets, and readies them. She also replans mid-flight and runs the epic closure check. On v7 she replaces `bmad-spec`, `bmad-ticket` and `bmad-correct-course`.
3. **Select the source.** Set `[stories] source = "tickets"` in `.bmad-loop/policy.toml`.
4. **Pick an initiative.** `[core] active_initiative` in `_bmad/custom/config.user.toml` must name an initiative whose folder exists, and not a parent initiative. Sofia records the initiative she creates there, and the `bmad` skill shows, switches, creates or clears it, so you rarely edit the file by hand.
4. **Pick an initiative.** `[core] active_initiative` in `_bmad/custom/config.user.toml` must name an initiative whose folder exists. A parent initiative passes the preflight only under `[scm] isolation = "worktree"` without `gates.retrospective = "auto"`. Sofia records the initiative she creates there, and the `bmad` skill shows, switches, creates or clears it, so you rarely edit the file by hand.
5. **Use a repo store.** With no `_bmad/custom/ticketing-store-config.toml` the store is the repo store. Sofia writes that file only when you connect a tracker or switch stores, and it must then say `store = "repo"`: tracker stores are refused.
6. **Check it.** Run `bmad-loop validate --probes`. Every `run` repeats the same `tickets.*` preflight and refuses on any problem.

Expand All @@ -19,7 +19,7 @@ Tickets mode is **experimental**. It is built and tested against BMAD-METHOD mai
| `tickets.config-utils-missing` | `_bmad/scripts/config_utils.py` exists; `tickets.py` loads it. |
| `tickets.uv-missing` | `uv` is on `PATH`. build-auto runs `tickets.py` with `uv run`. |
| `tickets.initiative-unset`, `tickets.initiative-missing` | `active_initiative` is set and its folder exists. |
| `tickets.initiative-parent` | the active initiative is no parent initiative: a run builds one child at a time. |
| `tickets.initiative-parent` | the active initiative is no parent, unless worktree isolation runs without the auto retrospective. |
| `tickets.store-not-repo` | the store is a repo store, not a tracker. |
| `tickets.store-topology` and its siblings | the store layout is one tickets runs can drive (see [Store layouts](#store-layouts)). |
| `tickets.branch-per-run` | a warning: `[scm] branch_per = "run"` puts every ticket on one branch. |
Expand All @@ -39,7 +39,7 @@ An initiative folder holds its own `<initiative>.md` and, in a `tickets/` folder

bmad-loop reads each folder the way the installed `tickets.py` does: a folder with a `tickets/` folder inside it is read there, and any other folder is read as the preview-era flat layout. A preview-era tree therefore still runs with the preview-era `tickets.py` it was installed with. The current `tickets.py` refuses that layout; `bmad migrate method` moves the tree into `tickets/` folders.

A parent initiative, whose `tickets.toml` lists child initiatives in `[[initiative]]` tables, is refused (`tickets.initiative-parent`): set `active_initiative` to one child. An epic the initiative declares but has not sliced yet has no folder; it holds no tickets, and the tickets gated after it wait for it.
A parent initiative, whose `tickets.toml` lists child initiatives in `[[initiative]]` tables, holds no tickets of its own, and each child's folder sits beside the parent's. The preflight refuses a parent (`tickets.initiative-parent`) under `[scm] isolation = "none"`, because sessions in the main checkout read the parent from `config.user.toml`, and under `gates.retrospective = "auto"`, whose session also runs in the main checkout. Set `active_initiative` to one child, or switch to worktree isolation without the auto retrospective. There the parent is an ok line naming its children, and the store checks run once for each child that has a folder: a child declared but not sliced yet is skipped. Each child is held to the `config.user.toml` rule of a shared store whatever its layout (`tickets.own-repo-isolation`). An epic the initiative declares but has not sliced yet has no folder; it holds no tickets, and the tickets gated after it wait for it.

## Store layouts

Expand Down
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,7 @@ strict = [
"src/bmad_loop/plugins/grants.py",
"src/bmad_loop/worksource.py",
"src/bmad_loop/ticketmodel.py",
"src/bmad_loop/initiativechain.py",
"src/bmad_loop/ticketstore.py",
"src/bmad_loop/usagereset.py",
"src/bmad_loop/parked.py",
Expand Down
25 changes: 24 additions & 1 deletion src/bmad_loop/bmadconfig.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@
import errno
import stat
import tomllib
from dataclasses import dataclass, field
from dataclasses import dataclass, field, replace
from pathlib import Path
from typing import Literal

Expand Down Expand Up @@ -167,6 +167,29 @@ def rebased(self, new_root: Path) -> ProjectPaths:
shared_store=self.shared_store,
)

def with_initiative(self, child: str) -> ProjectPaths:
"""These paths re-rooted on ``child``, a child initiative of the active
(parent) one: its folder is the parent folder's sibling named ``child``
(upstream ``tickets.py``'s ``folder.parent / slug``), and both artifact
dirs — so the ``deferred-work.md`` ledger and the sprint-status path with
them — move onto it, as :func:`_assemble_v7` places them for an active
initiative. ``active_initiative`` names the child; every other member is
kept. Pure: nothing is read, written or canonicalized. Raises ValueError
off v7, with no active initiative folder, or for a ``child`` that is not a
single folder name."""
if self.era != "v7" or self.initiative_folder is None:
raise ValueError("with_initiative needs a v7 project with an active initiative folder")
if child in ("", ".", "..") or Path(child).name != child or "\\" in child:
raise ValueError(f"child initiative {child!r} is not a single folder name")
folder = self.initiative_folder.parent / child
return replace(
self,
implementation_artifacts=folder,
planning_artifacts=folder,
active_initiative=child,
initiative_folder=folder,
)

def in_shared_store(self, path: Path) -> bool:
"""True when ``path`` is the shared ticket store or lies under it."""
store = self.shared_store
Expand Down
Loading
Loading