Rework bmad-ticket: slim skill, obeya seam, list-first inception

- SKILL.md rewritten as plain bullets under a Router: Create / Slice (incept alias, any altitude) / Refine; drafting spine (draft -> gate -> publish -> validate) stated once for every route
- Obeya backing split to references/obeya-local.md (tree grammar, lifecycles, publishing, update gate, queries); destination resolves from active_space to {obeya-root}, else asks, else {output_folder}/tickets/
- Epic inception is list-first: the story list drafts in the epic file itself (type + title + one-line intent, no statuses; a materialized file supersedes its entry), materialization all-at-once or JIT, each file the spec the build lane receives; readiness check offers architecture/spec/UX before slicing when missing and relevant
- hitl redefined: marks work a human must perform, never derived from risk — hitl_threshold removed from customize, update_ticket.py (both homes), and tests
- Tasks and stories share one template (task = story-shaped enabler or human-in-the-loop work); task-template.md deleted; initiative-template.md added (an initiative is a ticket)
- customize.toml: store/build_skill/project_root/hitl_threshold removed; initiative_template and task_template added; lifecycle vocabulary resolved from config, never hardcoded in prose
- bmad-build step-01 aligned to the claim + Verify acceptance-criteria format; stale evals removed pending a rebuild against the new shape
- Dual-homed test files resynced (initiative rename + new hitl tests in both homes)
This commit is contained in:
Brian Madison
2026-08-19 00:51:19 -05:00
parent d3c06add2e
commit 099b9430ed
26 changed files with 344 additions and 536 deletions
+83 -74
View File
@@ -1,103 +1,117 @@
---
name: bmad-ticket
description: Turns any input into epics, stories, bugs, tasks, and spikes. Use when the user says "make a ticket", "create a story", "break this into stories", "turn this PRD into epics", or "inception for [epic]".
description: Create, slice, and refine tickets at any altitude. Use when the user wants to create, modify, slice, refine, or incept/inception of epics, intents, specs, tickets, stories, and bugs.
---
# BMad Ticket
## Overview
Act as the user's slicing partner: they hold the product knowledge; you hold the craft of shaping work into tickets an agent can build from. Take almost any input — a sentence, a bug report, a PRD, a spec, the current conversation — and produce well-formed tickets in the tree at `{workflow.project_root}/tickets/`. The consumer sets the bar: a fresh context must be able to build from a ticket using only what it carries and points at — behavior, acceptance criteria with verification, dependencies, trace ids, typed-document pointers.
- Act as the user's slicing partner: they hold the product knowledge; you hold the craft of shaping work into tickets an agent can build from. Input is anything — a sentence, a bug report, a PRD, a spec, the current conversation.
- The consumer sets the bar: a fresh context must be able to build from a ticket using only what it carries and points at — behavior, acceptance criteria with verification, dependencies, trace ids, typed-document pointers.
- Three routes, cheap exit first: **Create** (one ticket, minimal ceremony), **Slice** (decompose any source one level down — a planning doc into epics, an epic into its story list via inception, an oversized story into thinner slices), **Refine** (improve, discuss, or split an existing ticket).
Three routes, cheap exit first. **Refine** writes one ticket with minimal ceremony. **Slice** decomposes open scope into the detailed epic set. **Incept** turns one epic into its stories. Never march a one-ticket request through inception altitude.
**Args:** plain language throughout — anything supplied up front is honored, never re-asked.
**Args:** plain language throughout — anything supplied up front is honored, never re-asked. `refine|slice|incept` picks the route (else detected from intent); `guided|quick|autonomous` the mode; `key=KEY` the initiative key at bootstrap; paths are source documents, forwarded to ingest verbatim; "one file" or v6 shapes route to `references/v6-migration.md`. On `--help`/`-h`: state this surface, point at `customize.toml` (team overrides: `_bmad/custom/bmad-ticket.toml`) and `uv run {skill-root}/scripts/ticket_tree.py --help`, then stop — no activation, no tree bootstrap, no side effects.
- `create|slice|refine` picks the route (else the Router detects it; `incept` is an accepted alias for slicing an epic); `guided|quick|autonomous` the mode; `key=KEY` the initiative key at bootstrap.
- Paths are source documents, forwarded to ingest verbatim. "One file" or v6 shapes route to `references/v6-migration.md`.
- On `--help`/`-h`: state this surface, point at `customize.toml` (team overrides: `_bmad/custom/bmad-ticket.toml`) and `uv run {skill-root}/scripts/ticket_tree.py --help`, then stop — no activation, no side effects.
## Resolution rules
- Bare paths and `{skill-root}` (e.g. `assets/story-template.md`) resolve from this skill's installed directory.
- `{project-root}` → the project working directory.
- `{workflow.<name>}` resolves to fields in `customize.toml`.
- `{obeya-root}` → the destination root (see Where tickets go).
## Where tickets go
- `{obeya-root}` is the destination: the active initiative's folder when `active_space` in `_bmad/config.toml` names one; empty, missing, or a plain space → ask which initiative (or none) as part of the one bundled question; no initiatives → `{output_folder}`, tickets landing in `{obeya-root}/tickets/`. This resolution moves to the shared workspace lookup when that lands — this skill just asks and writes; it is not the resolver.
- How tickets are stored, updated, and queried — the tree shape, the scripts, the rules — lives in `references/obeya-local.md`. Read it before writing. A different backend later (Jira, Linear) is a different reference file selected by central config; the craft in this file doesn't change. Lifecycle states and their transition graphs come from `customize.toml`, resolved at activation — never assumed.
## On Activation
1. Resolve customization: `uv run {project-root}/_bmad/scripts/resolve_customization.py --skill {skill-root} --key workflow`. On failure — including when `{project-root}/_bmad/` does not exist (standalone installs) — read `{skill-root}/customize.toml` directly; don't hunt for the resolver elsewhere.
2. Run `{workflow.activation_steps_prepend}`; treat `{workflow.persistent_facts}` as foundational context (`file:` entries are loaded).
3. Resolve config: `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root}`; from the merged JSON take `{communication_language}` and `{document_output_language}` (under `core`), `{date}`. Converse in `{communication_language}`; write ticket content in `{document_output_language}`. On failure take neutral language defaults and continue — the tree root comes from `{workflow.project_root}`, not from central config, so a missing `_bmad/` never blocks the run.
4. Open the floor before routing (interactive modes; autonomous routes straight from the request): invite everything the user has — planning docs, existing tickets, constraints, prior decisions, slicing preferences. Say the frame plainly: they are a coequal expert — you facilitate, they hold the product truth, and the more they bring the better the set. Validate what they point at (right docs, tree resolves) and announce what you'll use. Guidance that seems wrong, or contradicts the source or itself, gets a conversation, not silent compliance. A bare request gets "tell me everything"; a supplied path gets "what should I focus on?" — the dump replaces most ingest questioning. Then pick the route from intent — one ticket → refine; open scope or a planning doc → slice; an existing epic to break down → incept; updates and status questions need no route (the gate and tree queries answer them) — and the mode — **guided** (the default), **quick**, or **autonomous** (explicit invocation selects it). Ambiguity costs one bundled question, not a quiz.
5. Tree check: if `{workflow.project_root}/tickets/` does not exist, create it before any route runs — that is the whole bootstrap; there is no index to write. Settle the initiative key: if an initiative node (`{workflow.project_root}/ticket.md`) exists, its id prefix *is* the key and nothing is asked. Otherwise key precedence is the request, `{workflow.project_keys}`, then one bundled question — autonomous never asks: derive 35 uppercase letters from the initiative name and flag the derived key in the completion report. Creating the initiative node itself is not this skill's job — an initiative-scoped skill owns it; this skill works happily without one. If the tree exists, run `ticket_tree.py list` once to rebuild the landscape before routing.
3. Resolve config: `uv run {project-root}/_bmad/scripts/resolve_config.py --project-root {project-root}`; take `{communication_language}`, `{document_output_language}`, `{output_folder}` (under `core`), `{date}`, and `active_space`. Converse in the first; write ticket content in the second. On failure use neutral language defaults and resolve `{obeya-root}` by asking (suggest `{project-root}/.bmad-obeya`) — a missing `_bmad/` never blocks the run.
4. Open the floor before routing (interactive modes; autonomous routes straight from the request):
- Invite everything the user has — planning docs, existing tickets, constraints, prior decisions, slicing preferences. Say the frame plainly: they are a coequal expert; you facilitate, they hold the product truth.
- Validate what they point at (right docs, destination resolves) and announce what you'll use. Guidance that seems wrong, or contradicts the source or itself, gets a conversation, not silent compliance.
- A bare request gets "tell me everything"; a supplied path gets "what should I focus on?" — the dump replaces most ingest questioning.
- Route via the Router; mode: **guided** (default), **quick**, or **autonomous** (explicit invocation selects it).
- Ambiguity costs one bundled question, not a quiz.
5. Read `references/obeya-local.md` and run its activation check (key settlement, landscape scan; bootstrap only when this run will write — a read-only question against a missing tree is reported, never bootstrapped).
6. Run `{workflow.activation_steps_append}`.
## The ticket tree
## Router
```
{workflow.project_root}/ # the initiative folder — {project-root}/.bmad-obeya by default
├── ticket.md # the initiative node (type: initiative) — carries the key
├── prd/ · spec/ · brief/ # other skills' folders — never scanned as tickets
└── tickets/
├── alert-rules/ # epic = folder
│ ├── ticket.md # the epic node (id ALRT-3)
│ └── tickets/
│ └── ALRT-12-rule-crud.md # leaf (story|bug|task|spike)
└── ALRT-31-snooze-button.md # bin leaf — a leaf needs no epic
```
Explicit route arg wins; else detect from intent; else one bundled question. Updates and status questions need no route — the update gate and the tree queries answer them directly.
Rules that never bend:
- One new ticket from an idea, bug report, or fix → **Create**. "Create an initiative" lands here too (type initiative).
- Decompose something one level down — a planning doc or open scope into epics, an epic into its stories, an oversized story into thinner slices → **Slice** ("inception for X" lands here).
- An existing ticket to improve, discuss, or question → **Refine**.
- **A node is a folder.** Its own ticket is `ticket.md`; its children live in `<node>/tickets/`. The same shape repeats at every altitude — initiative, epic, sub-epic — so depth is never a special case, and a tree with no epics at all (loose leaves in `tickets/`) is a legitimate shape, not a degenerate one. Force depth only where the work earns it.
- **Parent = containing folder.** No parent field; a node never lists or counts its children, so parallel work never collides on a shared file.
- **One stored fact.** A leaf stores exactly one state field, `status`; blocked, frontier, next, rollups are derived by scan, never written down. No status ledger file, ever.
- **The folder is the listing.** There is no `index.md` and nothing to keep current. `ticket_tree.py index --out <file>` renders a navigation map on demand, an optional artifact the tree never depends on.
- **IDs are `KEY-n`.** The key is the initiative node's id prefix — one stored fact, never a second `key:` field to drift. Ids come from `uv run {skill-root}/scripts/ticket_tree.py next-id --root {workflow.project_root}`; gaps are meaningless. Leaf filename: `KEY-n-slug.md`.
- **The initiative node is optional.** With no initiative set, tickets sit in the work store's root `tickets/` bin — `.bmad-obeya/tickets/` out of the box — and the key is derived from the ids already issued. Everything else works unchanged.
- **Lifecycle.** Leaves: `backlog → in-progress → review → done` (or `dropped`, kept on the record). `review` means complete on a branch; `done` means merged — a dependent is workable only when its dependencies are `done`. Nodes run their own graph: `backlog → ready → in-progress → done` (or `dropped`), where `ready` means inception is finished and work can start. **Every node move is somebody's decision, never calculated** — an epic is done because a person looked at the outcome and agreed, so `done` can never flip when a child lands later, and archiving stays safe. `ready` records that call; it gates nothing (an unready epic with workable stories still blocks nobody). Progress against children — 3 of 5 done — is derived by `board` and stored nowhere: information about a node, not its state. This skill writes `backlog` at creation at both altitudes; the build lane and the user own the rest.
## The drafting spine
Every route, every mode:
1. Craft in drafts. All writing and revising happens in the drafts folder — `{workflow.drafts_dir}` (default: `.drafts/` inside the destination node) — never in the tree, never in system temp.
2. Write progressively as generated, never composed in memory and dumped at the end.
3. Gate, then publish. Nothing reaches the tree before the route's gate passes; publishing moves the approved files in and clears their drafts (mechanics in `obeya-local.md`).
4. Validate after every write to the tree; publish → validate → fix is one atomic step, and correcting the just-published files directly is legal inside that window.
5. Revising a published ticket later: pull it into drafts, revise, re-publish to the same location. Frontmatter changes go only through the update gate — never hand-edited.
6. Publishing lands tickets in the tree; syncing them anywhere else (Jira, Linear — via whatever CLI or MCP the user has) is a separate, user-driven step, never automatic.
## Writing a ticket
The type's template drives the shape: `{workflow.story_template}`, `{workflow.bug_template}`, `{workflow.task_template}`, `{workflow.spike_template}`, `{workflow.epic_template}`. Load the template for the type being written; fill every placeholder or cut the section — an unresolved placeholder never reaches disk. **A template is a checklist of categories, not a form to complete.** The fields a ticket needs morph with the work: a narrow change may be a two-sentence target, three criteria and a link, while a security change earns heavy boundaries. Cut what does not earn its place; a short ticket is a finished ticket, not a lazy one. Write approved files progressively as generated, never composed in memory and dumped at the end; in gated routes nothing touches disk before the gate passes.
The type's template drives the shape: `{workflow.story_template}`, `{workflow.bug_template}`, `{workflow.task_template}`, `{workflow.spike_template}`, `{workflow.epic_template}`, `{workflow.initiative_template}`. Load the template for the type being written. Stories and tasks share one template — a task is the same shape, pointed at a state of the world instead of user behavior.
Every ticket answers four things, whatever sections the template gives them: the **target** (the result and the why that constrains it), the **evidence** (each behavior paired with the check that proves it), the **boundaries** (what must not change), and the **coordinates** (where the authoritative context already lives).
- **A template is a checklist of categories, not a form to complete.** Fields morph with the work: a narrow change may be a two-sentence target, three criteria and a link; a security change earns heavy boundaries. Fill every placeholder or cut the section — an unresolved placeholder never reaches disk. A short ticket is a finished ticket.
- Every ticket answers four things, whatever sections the template gives them: the **target** (the result and the why that constrains it), the **evidence** (each behavior paired with the check that proves it), the **boundaries** (what must not change), and the **coordinates** (pointers to where the authoritative context lives — verified to open, never remembered).
The craft rules the templates cannot carry:
### Core ticket rules, regardless of template
- A story is a **vertical slice**: a narrow but complete path through every layer, demoable on its own. Its Behavior section is what the implementation plan gets built from. Its ACs are stable `#1..#n`, observable, atomic, bounded — outcomes, never engineer actions — each with a verify tail (a command, an endpoint, an observable). Given/When/Then is a per-criterion escalation when setup state genuinely matters, not the house style. When the story completes a user-visible flow, an e2e criterion says so.
- **Tighten a target until it decides.** If two reasonable implementations could satisfy the words and produce materially different outcomes, it is still too loose. The why is there to rule out the wrong-but-compliant reading.
- **Boundaries are a budget, not a checklist.** Each one spends the attention the implementation needs; include a constraint only when it rules out an otherwise valid solution. `Must not change:` is the load-bearing half — name adjacent behavior that could plausibly get damaged, and stop.
- **No prescribed how.** No implementation steps, no file paths or line numbers, no sample code — all stale before work starts, and a ticket cannot correct a plan mid-run. References are typed-document pointers: document type plus section. A stable contract (a schema, a state machine, an interface) may appear when the decision turns on its shape; sample implementation never does.
- A story is a **vertical slice**: a narrow but complete path through every layer, demoable on its own. Its Context tells that path in a few sentences.
- A **task** is an enabler: it unblocks a story (build the blocker, stand up the infra) or carries work needing a human in the loop. A **spike** is an enabler that researches, prototypes, or decides something — its answer can reshape the epic's stories or how they're tackled. When one layer of a slice needs invention or holds an unanswered question, don't let the story swallow it: spike or task, and the story `depends_on` them — the slice stays thin.
- ACs *are* the behavior spec the implementation plan gets built from: a stable numbered list (cross-referenced as "AC 3"), each criterion a **bold claim sentence** (observable, atomic, bounded — an outcome, never an engineer action) followed by its own indented `Verify:` line (a command, an endpoint, an observable). The claim says what must be true; the Verify line says how anyone proves it. **One claim per criterion** — a semicolon chain of assertions is several criteria wearing one number.
- **Sections have budgets.** Context 35 sentences; ACs aim for ~5, and more than 7 is a tripwire — stop and re-evaluate: the story splits, or criteria are implementation in disguise. A ticket that restates a referenced document's rules has swallowed the document — point at it instead.
- **Tighten the target until it decides.** If two reasonable implementations could satisfy the words with materially different outcomes, it is too loose — the why rules out the wrong-but-compliant reading.
- **Boundaries are a budget, not a checklist.** Include a constraint only when it rules out an otherwise valid solution. `Must not change:` is the load-bearing half — name adjacent behavior that could plausibly get damaged, and stop.
- **No prescribed how.** No implementation steps, code-location paths, line numbers, or sample code — all stale before work starts, and a ticket cannot correct a plan mid-run. A stable contract (schema, state machine, interface) may appear when the decision turns on its shape; sample implementation never does. The user is the exception: dev notes or must-follow guidelines they supply are recorded verbatim (Dev Notes), on the record as their call.
- **A UI story points at its design.** The design artifacts (`DESIGN.md`, prototype, ux folder) go in References; ACs stay functional — "submits a valid form containing X, Y, Z" — never a restated visual spec. Layout, theme, and spacing live in the design, referenced, not pasted. A UI story with no design link when one exists is missing something.
- **A reference is a path or it is nothing.** Every References entry cites a document by type plus a path that exists on disk right now, plus the section — and you open each one before the ticket is written. "The layout contract", "decided on <date>", "settled in session" are memories, not references: the reading agent has no session. A source that lives only in conversation gets written to disk first, then cited; a ticket whose source is not on disk is blocked on writing that source. The test: could a fresh agent, given only this file and the filesystem, open every source it names?
- `covers:` holds requirement ids verbatim from whatever scheme the input uses (CAP-4, FR-12, REQ-9) — never converted to a house scheme.
- Bugs carry a cause hypothesis, never a prescribed fix, and **"no change, here is the proof" is a passing result** — reproduce first, and if expected behavior already holds, the evidence is the deliverable and the bug closes `done`, not `dropped`. Severity is proposed in conversation on `{workflow.severity_scale}`.
- High-consequence work names independent evidence. When a hard floor trips or `hitl` is true, one check outside the ticket's own criteria is named — visible tests passing is not the same as the requirement being met.
- A story that reads like three stories is three stories — flag it and split.
- Bugs: reproduce first; carry a cause hypothesis, never a prescribed fix. **"No change, here is the proof" is a passing result** — if expected behavior already holds, the evidence is the deliverable and the bug closes `done`, not `dropped`. Severity is proposed in conversation on `{workflow.severity_scale}`.
- High-consequence work names independent evidence: when a hard floor trips (risk ≥ 4), name one check outside the ticket's own criteria — visible tests passing is not the same as the requirement being met.
- **The split gauge.** Signals a story is really several: more than 7 ACs; AC clusters demoable independently of each other; more than one vertical path through the layers; an "and" in the title joining unrelated outcomes; a Context that needs more than one narrative. Two or more signals → propose the split.
## Updating a ticket
## Scoring and HITL
Frontmatter on an existing ticket is never hand-edited — every change goes through the gate:
- Propose `risk` (15) with a one-line rationale across six dimensions: blast radius, reversibility, data sensitivity, security surface, novelty, production exposure. Mine what the project already knows first — project context, architecture, org knowledge.
- Hard floors bind what you propose and what autonomous mode writes: schema migrations, data deletion, auth, payments → risk 4 minimum.
- `hitl: true` means **a human must perform part of this work** — set up an account, configure something the AI cannot reach, approve something external. Set it only when such a step exists, name the human step in the ticket body, and never derive it from risk; the build lane treats it as blocked on that step.
- An explicit user decision overrides any of this — any allowed value, any combination; record their call and move on.
```
uv run {skill-root}/scripts/update_ticket.py --root {workflow.project_root} --id KEY-n \
--set status=in-progress [--set risk=4 ...] \
--transitions "<{workflow.lifecycle_transitions}, comma-joined>" \
--node-transitions "<{workflow.node_lifecycle_transitions}, comma-joined>" \
--hitl-threshold {workflow.hitl_threshold}
```
## Route 1 — Create
Pass the resolved values — overrides reach the gate only through those flags (omitted, the script uses its bundled defaults). The gate picks the graph from the ticket's type: leaves walk `--transitions`, nodes walk `--node-transitions`, and each altitude's vocabulary is refused at the other (`review` on an epic, `ready` on a story). It also refuses dependency edges that would close a cycle and raises `hitl` to true when a newly set risk crosses the threshold (never lowers it; an explicit `--set hitl=...` always wins). An off-graph move is refused with the legal moves named — relay that, and on the user's explicit decision re-run with `--force` (known states only; gibberish is always refused). Never work around the gate by hand-editing. The body is never touched.
## Scoring
Propose `risk` (15) with a one-line rationale across six dimensions: blast radius, reversibility, data sensitivity, security surface, novelty, production exposure. Hard floors bind what you propose and what autonomous mode writes on its own: schema migrations, data deletion, auth, payments → risk 4 minimum and `hitl: true`. Otherwise `hitl` defaults to risk ≥ `{workflow.hitl_threshold}`. An explicit user decision overrides any of this — any allowed value, any combination; record their call and move on. Mine what the project already knows first — project context, architecture, org knowledge. The six-dimension proposal is slice/incept altitude; refine derives from defaults and floors, states the result in one line, and moves on unless a floor trips or the user pushes back.
## Route 1 — Refine
"Make me a ticket for this fix." Elicit only what the template requires; propose the type from the input. Allocate the id and write one file — into the `tickets/` folder of the epic the user names or resolution finds, otherwise the bin (`{workflow.project_root}/tickets/`). Zero setup beyond activation's tree check; no review-lens pass — the user's confirm (or autonomous's floors plus `validate`) is refine's gate.
- "Make me a ticket for this fix." Elicit only what the template requires; propose the type from the input.
- Draft the one ticket and publish it into the epic the user names or a lookup resolves — otherwise the tickets bin at the destination root.
- "Create an initiative": settle the key, write the envelope from `{workflow.initiative_template}` at the destination root (bootstrap per `obeya-local.md`). The initiative is a ticket like any other — in an external tracker it maps to the top-altitude item.
- Zero setup beyond activation's check; no review-lens pass — the user's confirm is create's gate (autonomous: floors, then publish → validate → fix as one atomic step).
## Route 2 — Slice
Open scope in, the detailed epic set out through a human gate — envelopes with description, rationale, goals, and epic-level `covers:` (the what, never the how). **This route ends at epics**; stories come from incept, per epic, when that epic's work begins. Load `references/slice-epics.md` and run it. Greenfield project (nothing deployed, no CI): load `references/greenfield-guidelines.md` when proposing epics — Epic 1 is the scaffolding epic.
Decompose any source one level down, through a human gate. The source's altitude picks the reference:
## Route 3 — Incept
- **Open scope or a planning doc → the epic set.** Envelopes with description, rationale, goals, and epic-level `covers:` (the what, never the how). Load `references/slice-epics.md`. Greenfield project (nothing deployed, no CI): also load `references/greenfield-guidelines.md` when proposing epics — Epic 1 is the scaffolding epic. This leg ends at epics; each epic's stories come later, when its work begins.
- **An epic → its children** (inception). List-first: the initial story list is drafted in the epic file itself — per entry a type (story|task|spike), a short self-contained title, and one line of what it does, enough to convey intent — and sequencing is aligned on that list. Only after the list stands are ticket files materialized from their templates: all at once, or just-in-time per story when its work begins — the user's choice. The materialized file *is* the spec the coding agent builds from. The envelope is co-authored working state — re-read it fresh; detail a PM added since slicing is input, never drift. Load `references/incept-stories.md`.
- **An oversized story → thinner slices** (plus enabler tasks and spikes). Same craft and gate: load `references/incept-stories.md` with the story as the source. The original story is superseded by its children — dropped or reshaped into one of them, the user's call, on the record.
One epic in, its stories out through the breakdown-quiz gate. The epic's envelope is co-authored working state — re-read it fresh; detail a PM added since slicing is input, never drift. Load `references/incept-stories.md` and run it.
## Route 3 — Refine
- An existing ticket to improve or discuss. Resolve it (see Finding a named ticket), read it and what it points at, then work the conversation: tighten the target, sharpen ACs, fix references — body changes through the drafting spine (pull into drafts, revise, re-publish), frontmatter through the update gate.
- When the split gauge fires, recommend slicing; on agreement, hand to Slice with this ticket as the source.
- Gate: the user's confirm.
## V6 compatibility
@@ -107,32 +121,27 @@ A request for the single epics-and-stories file, all stories for all epics up fr
Routes say what gets made; modes say how collaboratively.
| Mode | Refine | Slice / Incept |
| Mode | Create / Refine | Slice |
|---|---|---|
| **Guided** (default) | Elicit, propose, confirm | Facilitated working session; per-epic / per-story questioning; iterate until approved |
| **Quick** | Propose the complete ticket, one confirm | Clarify from the dump, skeleton, full reviewed draft, one revision round, gate |
| **Guided** (default) | Elicit, propose, confirm | Facilitated working session; per-item questioning; iterate until approved |
| **Quick** | Propose the complete result, one confirm | Clarify from the dump, skeleton, full reviewed draft, one revision round, gate |
| **Autonomous** | Write it, defaults and floors applied | No questions; reviews still run; gate quiz on yourself, self-check recorded in the envelope's Sequencing Notes |
Two rules shape every multi-artifact run:
- **Skeleton before the expensive write.** When a run will produce several artifacts (an epic set, an epic's stories), present the skeleton first — each item as a title and a one-line summary (plus proposed `covers:`) — and let the user reshape the set (autonomous builds the skeleton but skips the pause). Full drafting, scoring, and review happen only after the skeleton stands; reshaping a title costs nothing, reshaping five finished stories costs everything.
- **The user never sees an unreviewed draft.** Once the full draft exists, run the review lenses in `{workflow.finalize_reviewers}` over it (parallel subagents where available), fold the findings in, and record material findings and their dispositions in Sequencing Notes or the item's Dev Notes. What gets presented — or what autonomous mode approves on its own — is the post-review draft.
- **Skeleton before the expensive write.** When a run will produce several artifacts (an epic set, an epic's stories), present the skeleton first — each item a title, a one-line summary, and proposed `covers:` — and let the user reshape the set (autonomous builds the skeleton but skips the pause). For epic inception the skeleton *is* the story list in the epic file. Draft, score, and review only after the skeleton stands: reshaping a title is cheap; reshaping five finished stories is not.
- **The user never sees an unreviewed multi-artifact draft.** (Create and Refine are gated by their confirm instead.) Once the full draft exists in drafts, run the review lenses in `{workflow.finalize_reviewers}` over it (parallel subagents where available) and fold the findings in. The review always includes the resolvability check: open every path the draft's References name — an entry that does not resolve on disk is a blocking finding, same severity as a missing AC. Record material findings as one-line dispositions (what changed or why declined) in Sequencing Notes or the item's Dev Notes — never finding counts, scorecards, or process narrative: an envelope is planning input, not an audit log. What gets presented — or what autonomous approves on its own — is the post-review draft.
All questioning is bounded: a handful, each with a recommended answer, bundled through the harness question tool where available — never a one-at-a-time drip.
An autonomous run ends by reporting status — `complete`, or `blocked` with a one-line reason — plus the tree root and the ids/paths created, so a caller can chain (slice, then incept per epic) without re-scanning the tree.
An autonomous run ends by reporting status — `complete`, or `blocked` with a one-line reason — plus `{obeya-root}` and the ids/locations created, so a caller can chain (slice, then slice per epic) without re-scanning.
## Handoff to build
## Working the tree
A workable leaf is the build lane's work input, so a run that wrote leaves says what runs next: `bmad-build` (or `bmad-build-auto` when the loop is unattended), pointed at the leaf path — `{workflow.project_root}/tickets/<epic>/tickets/KEY-n-slug.md`, or the bin leaf `{workflow.project_root}/tickets/KEY-n-slug.md`. The build lane reads the ticket as its whole work definition; nothing is exported and nothing is copied. Name the paths just written, and `frontier` (`uv run {skill-root}/scripts/ticket_tree.py frontier --root {workflow.project_root}`) as the way to ask what is workable now — that is the whole spec → ticket → build chain. Epics are not build inputs: an epic that just got its stories hands off to incept's output, not to build.
- Derived state is never hand-computed — use the query commands in `obeya-local.md` (frontier, board, coverage, graph, validate).
- When the user asks to optimize sequencing or dependencies — and as an offer after slicing writes leaves — render the dependency graph and walk the lanes with them: false edges, over-serialized independents, the critical path.
- When `done` lands on an epic, offer the archive (mechanics in `obeya-local.md`). An offer, never automatic.
## Finding a named ticket
A loosely named target resolves in tiers: exact id/slug/title match (`ticket_tree.py list` is the lookup table) → proceed; close matches → ranked candidates, the user picks; nothing → read the tree (`list`, or the folders themselves) and reason from it. Never guess ambiguity away.
## Tree queries
Derived state is never hand-computed: `uv run {skill-root}/scripts/ticket_tree.py <verb> --root {workflow.project_root}``next-id` before allocating, `validate` after every write (schema, placeholders, dep resolution, cycles — fix what it names before presenting), `list` for the id/title/status/path inventory, `frontier` for "what's workable now," `board` for rollups (each node's stored state plus its derived child counts), `coverage --require "<ids>"` for the coverage check (`--proposed` pre-gate, before anything is on disk), `graph --mermaid` for the dependency graph, parallel lanes, and critical path, `index --out <file>` to render a navigation map when someone wants one on disk (optional — nothing depends on it), `render --out <file>` for the single epics-and-stories markdown view when someone wants one file to read or share (generated; the tree stays the source of truth). When the user asks to optimize sequencing or dependencies — and as an offer after incept writes — render `graph --mermaid` and walk the lanes with them: false edges, over-serialized independents, the critical path. When `done` lands on an epic (the gate returns the hint), offer the archive: `archive --epic KEY-n` moves its stories to the dated `.archive/` record — the envelope stays as the durable layer — or `--purge` removes them when the record of truth lives elsewhere (e.g. synced to Jira). An offer, never automatic.
References: `slice-epics.md` (Route 2) · `incept-stories.md` (Route 3) · `greenfield-guidelines.md` (net-new project, at epic proposal) · `v6-migration.md` (v6 shapes + migration) · type templates in `assets/` via `{workflow.<type>_template}`.
References: `obeya-local.md` (the ticket tree: shape, scripts, rules) · `slice-epics.md` (slice: scope → epics) · `incept-stories.md` (slice: epic → children, story → thinner slices) · `greenfield-guidelines.md` (net-new project, at epic proposal) · `v6-migration.md` (v6 shapes + migration) · type templates in `assets/` via `{workflow.<type>_template}`.
Run `{workflow.on_complete}` if set when we reach a terminal state.
@@ -3,13 +3,13 @@ schema: 1
id: [KEY-n]
type: bug
title: "[Symptom, not cause — what goes wrong]"
status: backlog
status: backlog # backlog → ready → in-progress → done, or dropped
depends_on: []
covers: []
discovered_from: ""
severity: [1-5]
risk: [1-5]
hitl: [true|false]
hitl: [true|false] # true only when a human must perform part of this work — name that step in the body
created: [YYYY-MM-DD]
---
@@ -33,9 +33,12 @@ created: [YYYY-MM-DD]
## Acceptance Criteria
- #1 A test reproduces the failure before any fix exists — verify: new regression test, red before green
- #2 [The corrected behavior, stated as an observable outcome] — verify: [the same regression test, after the fix]
- #3 [Adjacent behavior that must still hold once the fix lands] — verify: [suite, command, or observable that proves it]
1. **A test reproduces the failure before any fix exists.**
Verify: new regression test, red before green
2. **[The corrected behavior, stated as an observable outcome]**
Verify: [the same regression test, after the fix]
3. **[Adjacent behavior that must still hold once the fix lands]**
Verify: [suite, command, or observable that proves it]
## Boundaries
@@ -46,7 +49,7 @@ created: [YYYY-MM-DD]
## References
- [Typed-document pointers relevant to this bug: document type + section, never file paths or line numbers. Set `discovered_from` in the frontmatter to the KEY-n that revealed this, when known. Cut the section if there are none.]
- [Document citations relevant to this bug: document type + a path that exists on disk + section. A source that lives only in conversation is written to disk first, then cited. Set `discovered_from` in the frontmatter to the KEY-n that revealed this, when known. Cut the section if there are none.]
- Verification entry point: [Optional. The command or workflow that runs this area's checks — only when the project has one an agent would not infer. Cut otherwise.]
## Dev Notes
@@ -4,7 +4,7 @@ id: [KEY-n]
type: epic
title: "[The deliverable]"
description: "[One line — what this epic delivers, in the reader's language]"
status: backlog
status: backlog # backlog → ready → in-progress → done, or dropped
depends_on: []
covers: []
risk: [1-5 ceiling hint — leaves score individually]
@@ -15,22 +15,36 @@ created: [YYYY-MM-DD]
## Context
[12 sentences: what this epic delivers and for whom. One dev drives this to completion — sized accordingly.]
[13 sentences: what this epic delivers and for whom. One dev drives this to completion — sized accordingly.]
## Goals
[Budget: a handful of outcome bullets. Detail lives in the referenced documents — an envelope that restates a contract or spec has swallowed it; point at it instead.]
- [What done looks like at epic level — outcomes, not a story list. Children are never enumerated here.]
## Stories
[The inception list — written during this epic's inception, before ticket files exist. One entry per child: type + short self-contained title + one line of what it does, enough to convey intent. No statuses, ever — this is slicing intent, not tracking; a materialized ticket file supersedes its entry (the file wins), and board/frontier read files only. Cut this section until inception happens.]
- [story|task|spike] [Title] — [what it does, one line]
## Sequencing Notes
[The sequencing rationale from inception: what gates what and why, in prose. The edges themselves live in the children's depends_on. Autonomous-mode self-check outcomes are recorded here.]
## Out of Scope
[Optional — what this epic deliberately does not cover.]
[Optional — what this epic deliberately does not cover. Never invent exclusions to fill the section.]
<!-- status: backlog → ready → in-progress → done, or dropped. `ready` means
inception is finished and work can start; it gates nothing, it records
that someone decided. `done` is likewise somebody's call, never a counter
hitting zero. Move it through scripts/update_ticket.py, never by hand. -->
## Open Questions
[Optional — cut when empty. Scope-level unknowns to settle between slicing and inception, each named plainly; what incept must resolve before stories are cut. Never a parking lot for decisions the user already made. Each Question starts with a Q#]
- Q{n}: [The unknown, and what it blocks]
## References
[Every entry cites a real path that exists and is accessible on disk or remote]
- [document type — path §section]
@@ -0,0 +1,31 @@
---
schema: 1
id: [KEY-1]
type: initiative
title: "[The initiative]"
description: "[One line — what this initiative exists to achieve]"
status: backlog # backlog → ready → in-progress → done, or dropped
created: [YYYY-MM-DD]
---
# [KEY-1] — [Title]
## Context
[24 sentences: the effort, who it serves, why now. The id prefix is the key that names every ticket underneath. No risk, no hitl — those are execution-altitude.]
## Goals
[Budget: a handful of outcome bullets at initiative altitude. Detail lives in the referenced documents — an envelope that restates a brief or spec has swallowed it; point at it instead.]
- [What done looks like for the whole initiative — outcomes, not an epic list. Children are never enumerated here.]
## Delivery Order
[Optional — the intended epic sequence in prose, and why. The edges themselves live in the epics' depends_on.]
## References
[Every entry cites a real path that exists and is accessible on disk or remote]
- [document type — path §section]
@@ -3,12 +3,12 @@ schema: 1
id: [KEY-n]
type: spike
title: "[The question being answered]"
status: backlog
status: backlog # backlog → ready → in-progress → done, or dropped
depends_on: []
covers: []
discovered_from: ""
risk: [1-5]
hitl: [true|false]
hitl: [true|false] # true only when a human must perform part of this work — name that step in the body
created: [YYYY-MM-DD]
---
@@ -20,8 +20,10 @@ created: [YYYY-MM-DD]
## Research Questions
- #1 [Specific question the spike must answer] — verify: [the artifact or measurement that constitutes the answer — a recorded benchmark run with raw numbers, a written comparison with stated assumptions, a decision record naming the deciding criterion. Not a test, and never "research it"]
- #2 [...] — verify: [...]
1. **[Specific question the spike must answer]**
Verify: [the artifact or measurement that constitutes the answer — a recorded benchmark run with raw numbers, a written comparison with stated assumptions, a decision record naming the deciding criterion. Not a test, and never "research it"]
2. **[...]**
Verify: [...]
## Timebox
@@ -36,7 +38,7 @@ created: [YYYY-MM-DD]
## References
- [Typed-document pointers relevant to this spike: document type + section, never file paths or line numbers. Cut the section if there are none. Often thin — the authoritative answer does not exist yet.]
- [Document citations relevant to this spike: document type + a path that exists on disk + section. A source that lives only in conversation is written to disk first, then cited. Cut the section if there are none. Often thin — the authoritative answer does not exist yet.]
- Verification entry point: [Optional. The command or workflow that reproduces the measurement — only when the project has one an agent would not infer. Cut otherwise.]
## Dev Notes
@@ -1,14 +1,14 @@
---
schema: 1
id: [KEY-n]
type: story
title: "[Outcome-focused title — what the user/system can do after]"
status: backlog
type: [story|task]
title: "[Outcome-focused title — what the user/system can do after; for a task, what exists/works after]"
status: backlog # backlog → ready → in-progress → done, or dropped
depends_on: []
covers: []
discovered_from: ""
risk: [1-5]
hitl: [true|false]
hitl: [true|false] # true only when a human must perform part of this work — name that step in the body
created: [YYYY-MM-DD]
---
@@ -16,17 +16,18 @@ created: [YYYY-MM-DD]
## Context
[13 sentences: why this story exists and where it fits in the epic. No file paths.]
## Behavior
[The end-to-end behavior being built, from the user's perspective, in prose — a narrow but complete path, not a layer-by-layer implementation list. This is what the implementation plan gets built from.]
[35 sentences: why this story exists, what someone can do when it is done, and the narrow path through the system in one breath. This is the whole narrative — the acceptance criteria below carry the behavior, so this section never restates them. Detail that does not fit here belongs in a referenced document, not in this ticket. A task — an enabler to a story, or work needing a human in the loop — uses this same shape: its Context says what the new state unlocks, the work that cannot proceed until it exists.]
## Acceptance Criteria
- #1 [Observable, atomic, bounded outcome — user/system behavior, never an engineer action] — verify: [how to confirm: command to run, endpoint to hit, thing to observe]
- #2 [One claim per criterion] — verify: [...]
- #3 (e2e) [When this story completes a user-visible flow: the end-to-end criterion proving the whole path works] — verify: [...]
<!-- Budget: aim for ~5. More than 7 is a tripwire — stop and re-evaluate: either the story is too big and splits, or criteria are implementation details disguised as ACs. Story criteria verify behavior; task criteria verify a state of the world (infra up, tooling works, docs published). -->
1. **[Observable, atomic, bounded outcome — ONE claim, user/system behavior, never an engineer action; semicolon-chained claims are separate criteria]**
Verify: [how to confirm: command to run, endpoint to hit, thing to observe]
2. **[One claim per criterion — the bold sentence is the contract, the Verify line is the proof]**
Verify: [...]
3. **(e2e) [When this story completes a user-visible flow: the end-to-end criterion proving the whole path works]**
Verify: [...]
## Boundaries
@@ -35,11 +36,19 @@ created: [YYYY-MM-DD]
- May change: [The surface this story is authorized to touch — only when the behavior above does not already make it obvious.]
- Must not change: [Adjacent behavior, interface, or invariant a plausible implementation could damage while still satisfying the criteria above. Name behavior, never files.]
## Open Questions
[Optional — cut when empty. Implementation-detail unknowns only, each with a proposed default the implementer may override. Scope-affecting questions are resolved with the user before work begins, ideally at time of drafting the story.]
- Q{n}: [The unknown, and what it blocks]
## References
- [Typed-document pointers relevant to this story: "epic spec — §auth decisions", "ux — checkout flow", "test plan — scenarios 46", "architecture — data model". Document type + section, never file paths or line numbers.]
[Every entry cites a real path that exists on disk right now — in the obeya or the repo — plus the section: "layout contract — v7/artifact-layout-contract/CONTRACT.md §Promotion". A reference the implementer cannot open is not a reference: if the authoritative source lives only in conversation, it gets written to disk first, then cited — "settled in session" or a bare date is a memory, not a pointer. Cut the section if there are none.]
- [document type — path §section]
- Verification entry point: [Optional. The command or workflow that runs this story's checks — only when the project has one an agent would not infer. Cut otherwise.]
## Dev Notes
[Optional. Constraints and gotchas. A stable contract (schema, state machine, interface) may be reproduced when it is the decision — never sample implementation code, never a pasted code wall, never file paths.]
[Optional. What an agent cannot discover from the sources above: where the work lands, a frozen interface (schema, CLI shape, state machine) when the decision turns on it, constraints, gotchas, decisions reviewed-and-declined so they are not relitigated. Never sample implementation code, never a step-by-step plan. These notes can come from the user conversationally also, reminders from previous ticket implementations, or call outs critical from references.]
@@ -1,40 +0,0 @@
---
schema: 1
id: [KEY-n]
type: task
title: "[Enabler outcome — what exists/works after]"
status: backlog
depends_on: []
covers: []
discovered_from: ""
risk: [1-5]
hitl: [true|false]
created: [YYYY-MM-DD]
---
# [KEY-n] — [Title]
## Context
[Why this enabler is needed, and concretely what the new state unlocks — the work that cannot proceed until it exists. Without that, the enabler gets optimized for the wrong thing.]
## Done Criteria
- #1 [Verifiable state of the world when this is complete — infra up, tooling works, docs published] — verify: [usually a command and its expected result, or an operational observable — not a test, since there is no user-visible behavior here]
- #2 [...] — verify: [...]
## Boundaries
[Optional — cut the whole section when nothing here rules out an otherwise valid solution. This is a budget, not a checklist: every line added competes for the agent's attention. `Must not change:` is the load-bearing half; write `May change:` only when the authorized surface is non-obvious.]
- May change: [The configuration or tooling surface this task is authorized to touch — only when it is non-obvious.]
- Must not change: [Live or shared state this task must leave alone — production configuration, shared credentials, an existing pipeline's behavior. Name behavior, never files.]
## References
- [Typed-document pointers relevant to this task: document type + section, never file paths or line numbers. Cut the section if there are none.]
- Verification entry point: [Optional. The command or workflow that exercises the new state — only when the project has one an agent would not infer. Cut otherwise.]
## Dev Notes
[Optional. Risk rationale when the score needs explaining (a hard floor tripped), constraints, gotchas.]
+20 -22
View File
@@ -12,22 +12,21 @@ activation_steps_append = []
persistent_facts = ["file:{project-root}/**/project-context.md"]
on_complete = ""
# The initiative folder — the root of one initiative's work. The skill reads
# and writes the ticket tree at {project_root}/tickets/, and the initiative
# node itself lives at {project_root}/ticket.md. Sibling folders (prd/, spec/,
# brief/) belong to other skills and are never scanned as tickets.
# Where pre-gate drafts are crafted. Empty = the default: a .drafts/
# folder inside the destination node. Set to a path for one fixed
# drafts location — safe to share, since publishing removes only its
# own files. Drafts are always local; only the gated publish step adds
# tickets to the tree.
#
# Default is the bare work store at {project-root}/.bmad-obeya, which is the
# "no initiative set" case: tickets land straight in its `tickets/` bin and
# the tree simply has no initiative node. Once an initiative is active, this
# resolves to {project-root}/.bmad-obeya/<initiative>. Pointing it somewhere
# else — a docs folder, a dedicated work-store repo — is a config change and
# a folder move, never a migration.
project_root = "{project-root}/.bmad-obeya"
# The destination itself is not configured here: it resolves from
# active_space in _bmad/config.toml (else {output_folder}/tickets/) —
# see SKILL.md "Where tickets go".
drafts_dir = ""
# Optional "initiative-name=KEY" entries consulted at tree bootstrap before
# asking (interactive) or deriving from the initiative name (autonomous —
# a derived key is flagged as unconfirmed in the completion report).
# Optional "initiative-name=KEY" entries consulted at key settlement
# before asking (interactive) or deriving from the initiative name
# (autonomous — a derived key is flagged as unconfirmed in the
# completion report).
project_keys = []
# Ticket templates, one per type. The templates drive the output shape,
@@ -35,9 +34,11 @@ project_keys = []
# shall-statements) swaps the template — the skill does not change.
story_template = "assets/story-template.md"
bug_template = "assets/bug-template.md"
task_template = "assets/task-template.md"
# A task is a story-shaped enabler (or human-in-the-loop work) — one template.
task_template = "assets/story-template.md"
spike_template = "assets/spike-template.md"
epic_template = "assets/epic-template.md"
initiative_template = "assets/initiative-template.md"
# Allowed leaf status moves ("from>to"). scripts/update_ticket.py refuses
# any move not listed unless --force carries an explicit user decision;
@@ -82,13 +83,10 @@ node_lifecycle_transitions = [
"in-progress>dropped",
]
# hitl derives from risk: hitl = (risk >= hitl_threshold), overridable
# per ticket. Hard floors (schema migrations, data deletion, auth,
# payments → risk 4 minimum, hitl true) are not configurable.
hitl_threshold = 3
# Review lenses run over the full draft before the user ever sees it
# (and before autonomous mode approves its own), plus any org standards
# Review lenses run over full multi-artifact drafts (the slice route)
# before the user ever sees them (and before autonomous mode approves
# its own) — create and refine are gated by the user's confirm
# instead. Plus any org standards
# (definition-of-ready, INVEST checklist). Entries are `skill:` or
# `file:` references or plain text. The baked entry names its three
# lenses explicitly, which runs them whatever the content class — the
@@ -1,149 +0,0 @@
{
"skill_name": "bmad-ticket",
"cases": [
{
"id": "refine-bug-quick",
"input": "quick mode: make a ticket for this bug, the report is in files/bug-report.txt. our ticket tree is at files/tree. dont ask me stuff just propose and write it.",
"rubric": [
"a new leaf file NOTI-4-*.md exists under files/tree with frontmatter type: bug and status: backlog; if it was placed inside an epic folder rather than the bin, the final message justifies the resolution (refine files into the epic resolution finds, otherwise the bin)",
"severity and risk are bare integers 1-5 with no unresolved placeholders anywhere in frontmatter, and hitl is consistent with the skill's rule (risk >= 3 gives hitl: true unless the user decided otherwise)",
"the body has Context, Observed Behavior, Expected Behavior, and Cause Hypothesis sections; the hypothesis engages the actual evidence (duplicate SMS history rows while exactly one Twilio send occurred; email unaffected) and prescribes no fix or solution steps",
"reported claims are attributed, not laundered into facts — the Thursday-deploy timing and the single-Twilio-send check are carried as the reporter's statements, not asserted as verified",
"the transcript shows ticket_tree.py next-id ran before the write and ticket_tree.py validate ran after it",
"no index.md was created anywhere in the tree — the folder is the listing",
"no clarifying questions were asked, and the final message surfaces the judgment calls made autonomously (type, severity, risk) rather than making them silently",
"files/bug-report.txt and all pre-existing ticket files are byte-identical to their committed state — only the new leaf changed"
],
"files": [
"files/bug-report.txt",
"files/tree/ticket.md",
"files/tree/tickets/notifications/ticket.md",
"files/tree/tickets/NOTI-3-snooze-button.md"
],
"timeout": 600
},
{
"id": "refine-autonomous-hard-floor",
"input": "autonomous: create a task ticket — nightly job that hard-deletes user accounts inactive for 90+ days, including the DB migration for the deletion-audit table. tree is at files/tree.",
"rubric": [
"a new task leaf exists under files/tree with frontmatter risk of 4 or 5 and hitl: true (data deletion + schema migration trip the hard floor), and the ticket records why the floor applies",
"the Done Criteria cover BOTH named deliverables — the nightly purge job and the deletion-audit-table migration; dropping either fails",
"the transcript contains no clarifying questions, and genuinely ambiguous product decisions (what counts as inactive, exemptions, run caps) are recorded in the ticket as open questions rather than silently invented",
"the body has numbered Done Criteria (#1, #2, ...) each stating a verifiable end state",
"the final message reports a status (complete) plus the tree root and the created ticket id/path",
"ticket_tree.py validate ran after the write and passed",
"all pre-existing ticket files are byte-identical to their committed state"
],
"files": ["files/tree/ticket.md", "files/tree/tickets/notifications/ticket.md", "files/tree/tickets/NOTI-3-snooze-button.md"],
"timeout": 600
},
{
"id": "refine-hitl-user-override",
"input": "User said: \"set risk 2 and hitl false — this touches the session-cleanup shim in the TEST environment only, I know exactly what it is. just write the ticket.\" (tree at files/tree)",
"state_prefix": "[refine route, mid-session: the skill proposed risk 4 / hitl true for a story touching session-auth cleanup, and the user replied:]",
"rubric": [
"the written ticket's frontmatter has risk: 2 and hitl: false exactly as the user decided — no silent restoration of the floor values",
"the reply contains no request to reconsider, no re-proposal of the floor values, and no risk warning attached to the write — acknowledgment of the user's call only",
"the override is recorded in the ticket itself (e.g. Dev Notes) as a user decision naming the floor it overrides, so a later reader will not 'correct' the score back",
"the ticket is well-formed: template sections present, status: backlog, no unresolved placeholders",
"a fresh run of ticket_tree.py validate over the tree passes",
"no pre-existing ticket file changed — only the new leaf differs from the fixture tree"
],
"files": ["files/tree/ticket.md", "files/tree/tickets/notifications/ticket.md", "files/tree/tickets/NOTI-3-snooze-button.md"],
"timeout": 600
},
{
"id": "refine-fresh-project-default-root",
"input": "autonomous: make a ticket out of files/bug-report.txt. brand-new project — nothing is configured yet and there is no ticket tree.",
"rubric": [
"the tree bootstrapped under .bmad-obeya/ with no configuration: the new leaf sits at .bmad-obeya/tickets/KEY-n-slug.md, and no tickets folder was created under _bmad-output/, docs/, or any other root",
"only the bin was created — no epic folder and no initiative ticket.md were invented for a single-ticket request",
"the initiative key was derived rather than asked for, and the final message flags it as unconfirmed alongside the tree root and the created path",
"the leaf is well-formed: type: bug, status: backlog, integer severity and risk, boolean hitl, and no unresolved placeholders",
"the transcript shows ticket_tree.py next-id ran before the write and ticket_tree.py validate ran after it",
"files/bug-report.txt is byte-identical to the fixture"
],
"files": ["files/bug-report.txt"],
"timeout": 600
},
{
"id": "slice-prd-autonomous",
"input": "autonomous: turn files/prd-notify.md into epics. put the ticket tree at tree-out, initiative key NOTI.",
"rubric": [
"tree-out/tickets/ holds one folder per epic written, each with a ticket.md envelope carrying status: backlog (slicing is not inception — ready is never claimed here), and no index.md exists anywhere",
"between 3 and 6 epic folders exist, each containing a ticket.md with type: epic, and each FR-n id from prd-notify.md appears in exactly one epic's covers: — verbatim ids, no duplicates across epics",
"no story, bug, task, or spike leaf files were created — the slice route stops at the epic set",
"every id FR-1 through FR-8 is covered or explicitly parked with a reason — none silently dropped — and the coverage check ran through ticket_tree.py coverage, not hand-computed",
"each envelope has Context, Goals, Sequencing Notes, and the autonomous self-check addresses the gate questions (boundaries, sizing, edges, merge/split) naming at least one considered-and-rejected alternative — a one-line 'self-check passed' fails",
"each epic's title AND Goals read as user-observable outcomes (an actor can do or see something); an epic whose Goals are only schema, queue, or framework work fails regardless of its title",
"epic-level depends_on edges form an acyclic graph with each edge's rationale in Sequencing Notes, and the set is not fully serialized into one chain when epics are genuinely independent",
"no scaffolding or foundation epic is invented — the PRD names an existing deployed stack with CI, and the decomposition respects that brownfield context",
"the PRD's non-functional notes (Twilio, SendGrid, 30-day retention/purge) are carried into the relevant envelopes as constraints rather than dropped",
"files/prd-notify.md is byte-identical to the fixture — the source was never edited",
"the transcript asked the user nothing; the final message reports status complete, the tree root, and the created epic ids/paths; ticket_tree.py validate passed over the finished tree"
],
"files": ["files/prd-notify.md"],
"timeout": 900
},
{
"id": "slice-guided-gate-approved",
"input": "User said: \"approved as presented — write it.\" (source: files/prd-notify.md; tree root tree-out; initiative key NOTI)",
"state_prefix": "[guided slice of files/prd-notify.md reached the gate. The presented and approved epic set is exactly: (1) 'Alert rule management' covering FR-1 and FR-8, no dependencies; (2) 'Real-time alert delivery' covering FR-2, FR-3, FR-6, depends on epic 1; (3) 'Rule snooze' covering FR-4, depends on epic 2; (4) 'Delivery history' covering FR-5, depends on epic 2; (5) 'Org-wide SMS spend cap' covering FR-7, depends on epic 2. The epic-quiz bundle was answered — granularity approved, every edge confirmed gating, nothing to merge or split — and the user then said:]",
"rubric": [
"exactly five epic envelopes are written whose titles, covers:, and depends_on relationships match the approved set field-for-field — a materially different set (merged, split, re-covered, or re-edged) fails even if defensible on its own merits",
"no additional epics and no story/bug/task/spike leaves are written",
"ticket_tree.py validate passes over the finished tree, and no index.md was created",
"the coverage check ran through ticket_tree.py coverage against FR-1..FR-8 and reported none uncovered",
"the quiz is not re-asked after approval and no new questions appear — and the silence is earned by output, not by inactivity",
"the final summary names the epics written and their covers"
],
"files": ["files/prd-notify.md"],
"timeout": 900
},
{
"id": "incept-epic-autonomous",
"input": "autonomous: inception for the notifications epic. tree is at files/tree.",
"rubric": [
"at least three new leaves with type: story and status: backlog exist under files/tree/tickets/notifications/tickets/ with filenames NOTI-n-slug.md, numbering continuing from the tree's next free id (no reuse of NOTI-2 or NOTI-3)",
"every new story has a Behavior section in prose, and Acceptance Criteria numbered #1, #2, ... where every verify tail names a concrete mechanism (a test level, command, endpoint, or assertion) — a bare 'verify: test it' fails",
"every story carries exactly one (e2e) criterion proving a user-visible flow end to end",
"story titles and Behaviors are user-visible outcomes — a layer-cake split (an 'API story' plus a 'UI story' for the same capability) fails",
"every leaf covers: id is drawn from the epic's covers set (FR-3, FR-4, FR-5 — no invented ids), and the union accounts for all three or explicitly parks the remainder",
"depends_on edges reference only existing ids, contain no cycles, are not fully serialized when stories are independent, and ticket_tree.py validate passes",
"the new stories sit in files/tree/tickets/notifications/tickets/ and no index.md was created",
"the envelope's Sequencing Notes gain the autonomous self-check addressing granularity, merging, edges, coverage, and risk — a one-line stamp fails — while the envelope's pre-existing human-authored Context, Goals, and original Sequencing Notes survive unmodified",
"NOTI-3-snooze-button.md is byte-identical to the fixture",
"every new leaf carries integer risk and boolean hitl; the transcript asked nothing; the final message reports status, tree root, and created ids"
],
"files": ["files/tree/ticket.md", "files/tree/tickets/notifications/ticket.md", "files/tree/tickets/NOTI-3-snooze-button.md"],
"timeout": 900
},
{
"id": "update-status-refusal-relay",
"input": "mark NOTI-3 as done please. tree is at files/tree.",
"rubric": [
"scripts/update_ticket.py was invoked carrying --id NOTI-3 and --set status=done — the requested change, not a quietly downgraded one — and no Write/Edit touched any tree file",
"every file under files/tree is byte-identical to the fixture — the refusal left the whole tree untouched",
"the final message relays the refusal faithfully: backlog to done is not legal, the legal moves from backlog are named, and the --force override is offered as the user's explicit choice — and any lifecycle or transition path the reply describes matches the script's actual transition graph (no invented states or illegal routes)",
"no --force invocation appears in the transcript — the skill did not force on its own"
],
"files": ["files/tree/ticket.md", "files/tree/tickets/notifications/ticket.md", "files/tree/tickets/NOTI-3-snooze-button.md"],
"timeout": 600
},
{
"id": "slice-greenfield-autonomous",
"input": "autonomous: slice files/greenfield-idea.md into epics — brand-new project, nothing exists yet. tree root tree-out, initiative key PANTRY.",
"rubric": [
"Epic 1 is a scaffolding epic naming CI, a deploy path, and test infrastructure — scaled to a solo free-tier hobby app (no stage environment, multi-region, SSO, or compliance gold-plating), with the calibration reasoning stated",
"files/greenfield-idea.md is byte-identical to the fixture, and the generated requirement inventory accounts for the braindump's concrete wants (receipt capture, running inventory, check-off, weekly expiry list, recipe suggestions, household sharing) — not a handful of vague stamped ids",
"requirements not stated in the braindump (e.g. sign-in) are flagged as derived rather than presented as given",
"between 3 and 8 epics exist; epics after Epic 1 are user-value slices, not technical layers",
"later epics depend on Epic 1 directly or through a chain, with rationale in Sequencing Notes — and independent feature epics are not serialized against each other",
"the epic carrying auth or household-sharing scores risk 4 or higher (auth floor) while read-mostly epics score lower — risk values are differentiated, not uniform",
"no leaf tickets were created; every epic written has its own folder with a ticket.md envelope under tree-out/tickets/, and ids carry the PANTRY key"
],
"files": ["files/greenfield-idea.md"],
"timeout": 900
}
]
}
@@ -1,3 +0,0 @@
from: support thread, customer #4411 (Meridian Health)
ok so the delivery history page is showing duplicate rows for SMS alerts. every sms alert shows up 2x sometimes 3x in history but the customer only actually received ONE text (we checked twilio logs, one send). email alerts look fine, only sms. started sometime after the thursday deploy. happens for all their users not just one. they're on the enterprise plan so this is getting escalated, pls make a ticket
@@ -1,3 +0,0 @@
# Idea braindump — PantryPal
Weekend project, just me. An app where I photograph my pantry/fridge receipts and it keeps a running inventory, then suggests dinner recipes from what I actually have, flagging stuff about to expire. Phone-first web app. I want to be able to check off ingredients as I use them, and get a weekly "use it or lose it" list. Maybe later share a household with my partner so we both update the same pantry. No revenue plans, this is for me and maybe some friends. I know React and a bit of Python. Happy to use some free-tier hosting.
@@ -1,19 +0,0 @@
# PRD — Notify: user-configurable alerts for Acme Dashboard
Acme Dashboard users miss critical account events because the product only surfaces them in-app. Notify adds real-time alerts users configure themselves. Existing system: Rails monolith, Postgres, Sidekiq, React front end, deployed on Heroku with CI already in place.
## Functional requirements
- FR-1: A user can create, edit, and delete alert rules (event type + threshold + channel) from account settings.
- FR-2: A user can choose delivery per rule: email, SMS, or in-app banner.
- FR-3: When a subscribed event fires, the matching rule delivers within 60 seconds.
- FR-4: A user can snooze any rule for 1 hour, 1 day, or 1 week without deleting it.
- FR-5: A user can view a 30-day delivery history per rule (sent, channel, delivered/failed).
- FR-6: Rule evaluation must not run more than once per event (no duplicate alerts).
- FR-7: An admin can set an org-wide cap on SMS sends per month.
- FR-8: All alert-rule changes are recorded in the existing audit log.
## Non-functional notes
- SMS via the existing Twilio account; email via the existing SendGrid integration.
- Delivery history retention: 30 days, then purge.
@@ -1,32 +0,0 @@
---
schema: 1
type: project
id: NOTI-1
title: "Notifications platform"
status: in-progress
description: "Real-time account alerts users configure themselves"
created: 2026-07-28
---
# NOTI-1 — Notifications platform
## Context
Users miss critical account events. This project delivers the alerting they can configure, quiet, and review for themselves.
## Goals
- A subscribed event reaches the user on their chosen channel, reliably and fast.
- Users stay in control of what reaches them, without losing their configuration.
## Non-Goals
- Marketing or campaign messaging. This is account-event alerting only.
## Success Signal
A user configures an alert, triggers the event, and sees it arrive on their channel — then silences it without reconfiguring anything.
## References
- prd — notification requirements FR-1..FR-8
@@ -1,24 +0,0 @@
---
schema: 1
id: NOTI-3
type: task
title: "Snooze button visual polish"
status: backlog
depends_on: []
covers: []
discovered_from: ""
risk: 1
hitl: false
created: 2026-07-30
---
# NOTI-3 — Snooze button visual polish
## Context
The snooze control looks cramped on mobile; design tweaks agreed with UX.
## Done Criteria
- #1 Snooze control matches the approved mobile mock at 375px width
- #2 No layout shift when the snooze menu opens
@@ -1,27 +0,0 @@
---
schema: 1
id: NOTI-2
type: epic
title: "Notification service"
description: "Real-time alerts users configure themselves"
status: ready
depends_on: []
covers: [FR-3, FR-4, FR-5]
risk: 3
created: 2026-07-28
---
# NOTI-2 — Notification service
## Context
Users miss critical account events; this epic delivers configurable real-time alerts end to end. One dev drives this to completion — sized accordingly.
## Goals
- A subscribed event reaches the user through their chosen channel within a minute.
- Users can quiet and review their alerts without losing configuration.
## Sequencing Notes
Delivery path first (it proves the pipeline), then snooze, then history — history reads what delivery writes.
@@ -1,18 +0,0 @@
[
{ "query": "make a ticket for the login timeout bug", "should_trigger": true },
{ "query": "can you break this PRD into epics for me", "should_trigger": true },
{ "query": "inception for the checkout epic", "should_trigger": true },
{ "query": "create a story for the dark mode toggle", "should_trigger": true },
{ "query": "add a spike to figure out which vector db we should use", "should_trigger": true },
{ "query": "break docs/spec.md into stories", "should_trigger": true },
{ "query": "turn this braindump into epics and get them on the wall", "should_trigger": true },
{ "query": "file a defect for the duplicate SMS history rows", "should_trigger": true },
{ "query": "write a PRD for our claims-triage tool", "should_trigger": false },
{ "query": "create a spec from this RFC", "should_trigger": false },
{ "query": "help me brainstorm feature ideas for the app", "should_trigger": false },
{ "query": "what does this stack trace mean", "should_trigger": false },
{ "query": "review this PR for bugs before I merge", "should_trigger": false },
{ "query": "run the architecture workflow for the payments service", "should_trigger": false },
{ "query": "draft the sprint retro notes", "should_trigger": false },
{ "query": "update the spec with the new capability we discussed", "should_trigger": false }
]
@@ -1,21 +1,22 @@
# Incept Route — one epic to its stories
# Slice — one node to its children
Standalone. Input: **one epic**its envelope, its epic-level spec if one exists, and whatever detail a human added since the epic was defined. Output: that epic's children — stories, tasks, spikes. Scope is this epic only; incepting several epics is this route run again per epic, on explicit request.
Standalone. Input: **one node to decompose** — usually an epic (its envelope, its epic-level spec if one exists, and whatever detail a human added since it was defined); an oversized story works the same way, as the source, its thinner slices plus enabler tasks/spikes as the children (the original story is superseded by them — dropped or reshaped into one, the user's call, on the record). Output: the aligned child list in the source's own file, and ticket files for whichever children the user chooses to materialize now. Scope is this node only; slicing several epics is this route run again per epic, on explicit request.
This is the facilitated working conversation that produces the epic's children — discussion, sequencing talk, story creation — not a generator run. Sometimes the PM is doing the detail work right here: facilitate it, and route what lands into the envelope or the epic spec, not into chat history. Re-read the envelope fresh at the start — human-added detail is input, never drift to regenerate away. Mode shapes the collaboration (guided = per-story questioning as slicing proceeds; quick = one pass over the set; autonomous = no questions, self-checked gate). Questioning is bounded — a handful of questions, each with a recommended answer, bundled through the harness question tool where available, never a mandated one-at-a-time drip. Converse in `{communication_language}`; write ticket content in `{document_output_language}`. Precondition: the tree exists — if `{workflow.project_root}/tickets/` is absent, create it and settle the initiative key once (the initiative node's id prefix when one exists, else ask). There is no index to write.
This is the facilitated working conversation that produces the epic's children — discussion, sequencing talk, story creation — not a generator run. Sometimes the PM is doing the detail work right here: facilitate it, and route what lands into the envelope or the epic spec, not into chat history. Re-read the envelope fresh at the start — human-added detail is input, never drift to regenerate away. Mode shapes the collaboration (guided = per-item questioning as slicing proceeds; quick = one pass over the set; autonomous = no questions, self-checked gate). Questioning is bounded — a handful of questions, each with a recommended answer, bundled through the harness question tool where available, never a mandated one-at-a-time drip. Converse in `{communication_language}`; write ticket content in `{document_output_language}`. Precondition: the obeya's **check** has run (activation step 5 — bootstrap and key settlement live there, not here).
1. **Ingest.** The envelope, the epic spec if present, and the documents they point at (UX, architecture, test plan — as relevant). Where a source folder holds a memlog, a distilled `.md`, and rendered `.html`, the non-memlog `.md` is the document to read. Always tell the user which documents you are reviewing.
2. **Governance check.** An epic-level spec is a **differential** contract, never a consolidation: it settles what the parent left underdetermined, and it fixes what must hold constant across this epic's stories. Nothing the parent already determines belongs in it — that is inherited by pointer, and copying it down both obscures which copy is authoritative and spends the attention budget the stories need. So the test is whether a delta exists: if the parent already determines enough for these stories to be written without drift, **no epic spec is needed** and you proceed. Where a genuine delta exists — an epic-level architecture call, a constraint every story must respect, an ambiguity the parent left open — offer to run `bmad-spec` for the epic before slicing, and let it carry the references to UX or other artifacts relevant at this altitude.
2. **Readiness check.** Before slicing, look at what the epic can stand on:
- **Missing foundations get offered, not assumed.** No architecture or solution design for this epic's ground, no spec, no UX where the work is clearly user-facing → offer to produce the relevant ones first (`bmad-architecture`, `bmad-spec`, `bmad-ux`) or proceed straight to the list — the user's call. Skip the offer when the higher altitude already covers it: a single-epic project whose PRD, spec, architecture, or UX were done at project level may need nothing new here, and an epic with no cross-story shared decisions has nothing for these documents to settle.
- **An epic-level spec is a differential contract, never a consolidation:** it settles what the parent left underdetermined and fixes what must hold constant across this epic's stories. Nothing the parent already determines belongs in it — that is inherited by pointer; copying it down obscures which copy is authoritative and spends the attention budget the stories need. No delta → no epic spec, and that is the healthy case.
- **Novel, challenging, or needs a real user decision** — and no document is going to settle it up front → that indicates **spikes** (research/prototype/decide; the answer may reshape the list) or enabler **tasks** in the list, not more paperwork.
3. **Slice — skeleton first.** In the interactive modes, propose the story skeleton before drafting anything expensive: each child as a title, a one-line summary, and proposed `covers:` — the user reshapes the set here, where a change costs nothing. Once the skeleton stands (autonomous proceeds without the pause), draft the full stories, tasks, and spikes from their templates: Behavior in prose, numbered behavioral ACs with verify tails (an e2e criterion where the story completes a user-visible flow), `depends_on` edges, `covers:` distributing the epic's requirement ids, typed-document pointers (epic spec, test plan, UX, architecture as relevant). Thinnest end-to-end slice first; vertical slices of deliverable functionality throughout — "fits a fresh context window" is a split heuristic, never a token budget. Sequencing talk happens in phase language; encode it as dependency edges and record the rationale in the envelope's Sequencing Notes. Surface a spike wherever genuine uncertainty blocks slicing.
3. **The list, in the epic file itself.** Draft the initial child list in the source node's own file (its Stories section) — per entry: the ticket type (story|task|spike), a short self-contained title, and one line of what it does, enough to convey intent. No ticket files yet. Sequencing talk happens in phase language on this list; record the rationale in Sequencing Notes and plan the `depends_on` edges here. Thinnest end-to-end slice first; vertical slices of deliverable functionality throughout — "fits a fresh context window" is a split heuristic, never a token budget. Surface a spike wherever genuine uncertainty blocks slicing. The epic file is published state, so this edit runs through the revision spine (pull into drafts, revise, re-publish). **List rules:** entries carry no status, ever — the list is slicing intent, not tracking; a materialized ticket file supersedes its entry (the file wins on any disagreement), and board/frontier read files only.
4. **Score.** Risk per leaf with the six-dimension rationale; hard floors bind what you propose (migrations, data deletion, auth, payments → risk 4 minimum, `hitl: true`) — an explicit user decision overrides. `hitl` derived from risk ≥ `{workflow.hitl_threshold}`; bug severity where applicable. The human confirms.
4. **Align the list — the inception gate.** Present the list (type · title · what it does · planned depends_on) and interrogate it: granularity right? every edge genuinely gating? anything to merge or split? Iterate until the user approves — per item in guided mode, once over the set in quick mode, run on yourself with outcomes recorded in Sequencing Notes in autonomous mode. Pre-gate coverage runs here: every id in the source's `covers:` lands on a listed child or is explicitly parked on the record — `uv run {skill-root}/scripts/ticket_tree.py coverage --root {obeya-root} --require "<the source's covers ids>" --proposed "<ids the list covers>"`. An aligned list is inception finished: offer the epic's `ready` move — through the update gate with the resolved flags (obeya-local.md "Updating a ticket"; `--set status=ready`) — and take the user's no as an answer; it records their decision, it gates nothing either way. (`ready` is epic vocabulary; a sliced story is superseded instead.)
5. **Coverage check.** Every id in the epic's `covers:` lands on a child or is explicitly parked on the record. Pre-gate, run the proposal through `uv run {skill-root}/scripts/ticket_tree.py coverage --root {workflow.project_root} --require "<the epic's covers ids>" --proposed "<ids the proposed children cover>"`; re-run without `--proposed` after the writes.
5. **Materialize — all at once, or just-in-time.** The user chooses: generate every ticket file now, or JIT each story when its work begins (this route re-entered per story, the list as input). A materialized file *is* the spec the coding agent builds from — full draft from its type's template into the drafts folder (`{workflow.drafts_dir}`, default the epic's own `.drafts/`): Context carrying the narrative path, numbered behavioral ACs — bold claim sentence plus its own `Verify:` line, per the template (an e2e criterion where the story completes a user-visible flow; section budgets bind — past ~7 ACs, stop and re-split), `depends_on` edges, `covers:` distributing the source's requirement ids, references citing sources by real on-disk path and section (epic spec, test plan, UX, architecture as relevant) — a source that lives only in this conversation gets written to disk before it is cited. **Score** each leaf as it materializes: risk with the six-dimension rationale; hard floors bind (migrations, data deletion, auth, payments → risk 4 minimum) — an explicit user decision overrides; `hitl: true` only where a human must perform part of the work (account setup, external config, an approval the AI cannot give) — name that step in the ticket; never derived from risk. Bug severity where applicable.
6. **Review, then gate.** Before the user sees the full draft — and before autonomous mode approves its own — run every entry in `{workflow.finalize_reviewers}` over it (review skills as parallel subagents where available; the edge-case and verification-gap lenses earn their keep here — missing ACs and ritual verify tails are this route's failure modes), fold the findings in, and record material findings with their dispositions in Sequencing Notes or the story's Dev Notes. Then the breakdown quiz: present the reviewed set (title · depends-on · what it delivers · risk) and interrogate it — granularity right? every edge genuinely gating? anything to merge or split? Iterate until approved — per story in guided mode, once over the whole set in quick mode, run on yourself with outcomes recorded in the envelope's Sequencing Notes in autonomous mode. **Nothing is written until the gate passes.** On approval: leaves written progressively as generated into the epic's own `tickets/` folder — `<the epic's folder>/tickets/KEY-n-slug.md`, ids via `uv run {skill-root}/scripts/ticket_tree.py next-id --root {workflow.project_root}` then validate what was written (`... validate`) — fix anything it names before presenting.
6. **Review, then publish.** Before the user sees the materialized drafts — and before autonomous mode approves its own — run every entry in `{workflow.finalize_reviewers}` over the drafts (review skills as parallel subagents where available; the edge-case and verification-gap lenses earn their keep here — missing ACs and ritual verify tails are this route's failure modes), plus the resolvability check: open every path each draft's References name; an entry that does not resolve on disk is a blocking finding. Fold the findings in and record material ones with their dispositions in Sequencing Notes or the story's Dev Notes — one line per finding, what changed or why declined; counts, scorecards, and process narrative never land in a ticket. **Nothing reaches `tickets/` until the user confirms the reviewed drafts.** On approval: move them into the source node's own `tickets/` folder — `<the node's folder>/tickets/KEY-n-slug.md`, ids via `uv run {skill-root}/scripts/ticket_tree.py next-id --root {obeya-root}` — clear their drafts, then validate what was written (`... validate`) — fix anything it names before presenting. Re-run coverage without `--proposed` once files exist.
Inception finishing is what `ready` on the epic means. Offer the move once the stories are on disk and validated — `update_ticket.py --id <epic> --set status=ready` — and take the user's no as an answer; it records their decision, it gates nothing either way.
After the writes, offer the sequencing look: `uv run {skill-root}/scripts/ticket_tree.py graph --root {workflow.project_root} --mermaid` renders the dependency graph, parallel lanes, and critical path — walk it with the user for false edges and over-serialized independents.
After materializing, offer the sequencing look: `uv run {skill-root}/scripts/ticket_tree.py graph --root {obeya-root} --mermaid` renders the dependency graph, parallel lanes, and critical path — walk it with the user for false edges and over-serialized independents.
@@ -0,0 +1,86 @@
# The ticket tree (local obeya)
Tickets are markdown files in a tree at `{obeya-root}` (resolved at activation — see SKILL.md "Where tickets go"). This file defines the tree's grammar and how to work it with the bundled scripts.
Script form, throughout: `uv run {skill-root}/scripts/ticket_tree.py <command> --root {obeya-root}`.
The scan reads a node's `ticket.md` and `tickets/**` only; dot-folders (`.drafts/`, `.archive/`) and sibling document folders are invisible to it.
## The tree
```
{obeya-root}/ # the initiative folder (or the tickets bin's parent)
├── ticket.md # the initiative node (type: initiative) — carries the key
├── prd/ · spec/ · brief/ # other skills' output folders — never scanned as tickets
└── tickets/
├── alert-rules/ # epic = folder
│ ├── ticket.md # the epic node (id ALRT-3)
│ └── tickets/
│ └── ALRT-12-rule-crud.md # leaf (story|bug|task|spike)
└── ALRT-31-snooze-button.md # bin leaf — a leaf needs no epic
```
## Rules that never bend
- **A node is a folder.** Its own ticket is `ticket.md`; children live in `<node>/tickets/`. Same shape at every altitude (initiative, epic, sub-epic) — depth is never a special case. A tree of loose leaves with no epics is a legitimate shape. Force depth only where the work earns it.
- **Parent = containing folder.** No parent field, and a node never carries tracking for its children — no statuses, counts, or rollups. (An epic body's inception Stories list is slicing intent, not tracking: a materialized ticket file supersedes its entry, and scans read files only.)
- **One stored fact.** A leaf stores exactly one state field, `status`. Blocked, frontier, next, rollups are derived by scan, never written down. No status ledger file, ever.
- **The folder is the listing.** No `index.md`, nothing to keep current. `index --out <file>` renders a navigation map on demand — optional; the tree never depends on it.
- **IDs are `KEY-n`.** The key is the initiative node's id prefix — one stored fact, never a second `key:` field to drift. Gaps in `n` are meaningless. Leaf filename: `KEY-n-slug.md`.
- **The initiative node is optional.** Without one, tickets sit in `{obeya-root}/tickets/` as a bin and the key derives from ids already issued. Everything else works unchanged. The envelope is created by the Create route on request (from `{workflow.initiative_template}`) or by an initiative-scoped skill.
## Lifecycles
- Leaves: `backlog → in-progress → review → done` (or `dropped`, kept on the record). `review` = complete on a branch; `done` = merged. A dependent is workable only when its dependencies are `done`.
- Nodes: `backlog → ready → in-progress → done` (or `dropped`). `ready` = inception finished, work can start. It records a decision and gates nothing — an unready epic with workable stories blocks nobody.
- **Every node move is somebody's decision, never calculated.** An epic is `done` because a person looked at the outcome and agreed — so `done` can never flip when a child lands later, and archiving stays safe. Progress against children (3 of 5 done) is derived by `board`, stored nowhere: information about a node, not its state.
- This skill writes `backlog` at creation, both altitudes. The build lane and the user own the rest.
## Activation check — bootstrap and key settlement
- If `{obeya-root}/tickets/` does not exist and the run will write, create it. That is the whole bootstrap — no index to write. A read-only question against a missing tree just reports that.
- Settle the key (writing runs only): if the initiative node (`{obeya-root}/ticket.md`) exists, its id prefix *is* the key — ask nothing. On an existing tree without one, the key is the prefix of the ids already issued. Otherwise precedence: the request → `{workflow.project_keys}` → one bundled question. Autonomous never asks: derive 35 uppercase letters from the initiative name — or, when none exists, from the request's subject or the obeya folder's name — and flag the derived key in the completion report.
- If the tree already exists, run `list` once to rebuild the landscape before routing.
## Publishing drafts
- Pre-gate drafts live in the drafts folder (see SKILL.md's spine; default `<destination node>/.drafts/`) — never in system temp.
- Ids are issued at publish time: `next-id` before every id — never invent or reuse ids by hand.
- Gate approval = move the approved files into the destination `tickets/` and delete their drafts — remove the drafts folder only when it holds nothing else. Nothing reaches `tickets/` before the gate passes.
- Publishing a node (an epic): create `<destination tickets/>/<slug>/` and place its draft as that folder's `ticket.md`.
- Destination: the `tickets/` folder of the epic the user names or a lookup resolves; otherwise the bin (`{obeya-root}/tickets/`) — the default spot for epic-less tickets.
## Updating a ticket — the gate
Frontmatter on an existing ticket is never hand-edited — every change goes through:
```
uv run {skill-root}/scripts/update_ticket.py --root {obeya-root} --id KEY-n \
--set status=in-progress [--set risk=4 ...] \
--transitions "<{workflow.lifecycle_transitions}, comma-joined>" \
--node-transitions "<{workflow.node_lifecycle_transitions}, comma-joined>"
```
- Pass the resolved values — overrides reach the gate only through those flags (omitted, the script uses bundled defaults).
- The gate picks the graph from the ticket's type: leaves walk `--transitions`, nodes walk `--node-transitions`; each altitude's vocabulary is refused at the other (`review` on an epic, `ready` on a story).
- It refuses dependency edges that would close a cycle.
- An off-graph move is refused with the legal moves named — relay that; on the user's explicit decision re-run with `--force` (known states only; gibberish is always refused).
- Never work around the gate by hand-editing. The body is never touched.
- **Archive** is an update consequence: when `done` lands on an epic the gate returns the hint — offer it, never run it automatically. `archive --epic KEY-n` (a `ticket_tree.py` command) moves the epic's stories to the dated `.archive/` record; the envelope stays as the durable layer. `--purge` removes them instead, when the record of truth lives elsewhere.
## Tree queries
- `list` — id / title / status / path inventory; also the lookup table for resolving a named ticket (id / slug / title).
- `validate` — after every write to the tree (publish, update, archive): schema, placeholders, dep resolution, cycles. Fix what it names before presenting.
- `frontier` — "what is workable now": every ticket not started whose dependencies are all done.
- `board` — rollups: each node's stored state plus its derived child counts.
- `coverage --require "<ids>"` — requirement-coverage check; `--proposed "<ids the proposed set covers>"` runs it pre-gate over the proposal, before anything is in the tree.
- `graph --mermaid` — dependency graph, parallel lanes, critical path.
- `index --out <file>` — navigation map on disk, when someone wants one (optional; nothing depends on it).
- `render --out <file>` — the single epics-and-stories markdown view, when someone wants one file to read or share (generated; the tree stays the source of truth).
## Handoff paths
- A leaf's path: `{obeya-root}/tickets/<epic>/tickets/KEY-n-slug.md`, or the bin leaf `{obeya-root}/tickets/KEY-n-slug.md`.
- The build lane reads the ticket file as its whole work definition — nothing is exported, nothing is copied.
- `frontier` is the standing answer to "what should I work on next."
@@ -2,20 +2,20 @@
Standalone. Input: any planning source — PRD, spec, brief, architecture doc, raw braindump, the current conversation. Output: **the detailed epic set and nothing below it** — folders and envelopes, each carrying description, goals, sequencing rationale, and epic-level `covers:` (the *what* at epic altitude, never the *how*). Stories are not produced here: they come from the incept route, one epic at a time, when that epic's work begins. Slicing every epic's stories in one sitting happens only when the user explicitly asks — then chain incept per epic after this route completes.
This is a facilitated working session, not a generator run. Mode shapes the collaboration (guided = discuss per epic; quick = one pass over the set; autonomous = no questions, self-checked gate). Questioning is bounded — a handful of questions, each with a recommended answer, bundled through the harness question tool where available, never a mandated one-at-a-time drip. Converse in `{communication_language}`; write ticket content in `{document_output_language}`. Precondition: the tree exists — if `{workflow.project_root}/tickets/` is absent, create it and settle the initiative key once (the initiative node's id prefix when one exists, else ask). There is no index to write.
This is a facilitated working session, not a generator run. Mode shapes the collaboration (guided = discuss per epic; quick = one pass over the set; autonomous = no questions, self-checked gate). Questioning is bounded — a handful of questions, each with a recommended answer, bundled through the harness question tool where available, never a mandated one-at-a-time drip. Converse in `{communication_language}`; write ticket content in `{document_output_language}`. Precondition: the obeya's **check** has run (activation step 5 — bootstrap and key settlement live there, not here).
A ticket-shaped aside raised mid-session (a defect, a follow-up, an out-of-scope idea) is noted and written as a bin leaf in `{workflow.project_root}/tickets/` at the end of the run, never chased mid-session; non-ticket detail (constraints, decisions) lands in the relevant envelope's Sequencing Notes.
A ticket-shaped aside raised mid-session (a defect, a follow-up, an out-of-scope idea) is noted and written as a bin leaf in `{obeya-root}/tickets/` at the end of the run, never chased mid-session; non-ticket detail (constraints, decisions) lands in the relevant envelope's Sequencing Notes.
1. **Ingest.** Read what the user points at, plus obvious siblings at the ticket root if they exist — no hardcoded glob archaeology. Where a source folder holds a memlog, a distilled `.md`, and rendered `.html`, the non-memlog `.md` is the document to read (memlog = working record; html = presentation render). Always tell the user which documents you are reviewing.
2. **Inventory.** Extract the requirement inventory in the input's own id scheme — CAP ids from a spec, FR/NFR/UX-DR from a PRD, REQ ids from an imported doc — whatever exists, verbatim. If the input has no stable ids, propose stamping them: generate the inventory in-session and offer (never silently apply) edits to the source doc. This inventory is the coverage contract.
3. **Propose the skeleton, then the set.** In the interactive modes, the skeleton comes first: each epic as a title, a one-line description, and proposed `covers:` — cheap to reshape, so the user reshapes it here, before any full envelope is drafted. Once the skeleton stands (autonomous proceeds without the pause), draft the full envelopes: goals, boundary rationale, `covers:`, and a scored `risk` (15, the ceiling hint for the epic's children) with a one-line rationale — hard floors bind here too (schema migrations, data deletion, auth, payments → 4 minimum). Epic altitude follows the doctrine: **the epic is the single-person unit of work** — one dev drives it to completion with AI as the workforce. User-value-first; no technical-layer epics. Other boundaries (component split, team boundary) are legal and discussable, but single-person ownership is the recommendation that calibrates sizing. Relative sizing on request happens at this altitude — T-shirt size the epic set from the envelopes and the inputs they link; no stories required. Greenfield project (nothing deployed, no CI) → Epic 1 is the scaffolding epic, calibrated to the project's what/who/purpose — `references/greenfield-guidelines.md` carries the doctrine.
3. **Propose the skeleton, then the set.** In the interactive modes, the skeleton comes first: each epic as a title, a one-line description, and proposed `covers:` — cheap to reshape, so the user reshapes it here, before any full envelope is drafted. Once the skeleton stands (autonomous proceeds without the pause), draft the full envelopes — into the drafts folder (`{workflow.drafts_dir}`, default the destination node's `.drafts/`), never system temp: goals, boundary rationale, `covers:`, references citing the driving inputs by real on-disk path and section (a source that lives only in this conversation gets written to disk before it is cited), and a scored `risk` (15, the ceiling hint for the epic's children) with a one-line rationale — hard floors bind here too (schema migrations, data deletion, auth, payments → 4 minimum). Epic altitude follows the doctrine: **the epic is the single-person unit of work** — one dev drives it to completion with AI as the workforce. User-value-first; no technical-layer epics. Other boundaries (component split, team boundary) are legal and discussable, but single-person ownership is the recommendation that calibrates sizing. Relative sizing on request happens at this altitude — T-shirt size the epic set from the envelopes and the inputs they link; no stories required. Greenfield project (nothing deployed, no CI) → Epic 1 is the scaffolding epic, calibrated to the project's what/who/purpose — `references/greenfield-guidelines.md` carries the doctrine.
4. **Sequence.** The conversation happens in phase language ("scaffolding first, then the core flow"); encode it as epic-level `depends_on` edges and record the rationale in each envelope's Sequencing Notes — there is no phase field, and none is invented.
5. **Coverage check.** Every inventoried requirement id lands on some epic or is explicitly parked — "not yet" on the record is distinct from "forgot." Run it through the script both times: pre-gate on the proposal (`uv run {skill-root}/scripts/ticket_tree.py coverage --root {workflow.project_root} --require "<ids>" --proposed "<ids the proposed set covers>"`), and again once envelopes are on disk (drop `--proposed`).
5. **Coverage check.** Every inventoried requirement id lands on some epic or is explicitly parked — "not yet" on the record is distinct from "forgot." Run it through the script both times: pre-gate on the proposal (`uv run {skill-root}/scripts/ticket_tree.py coverage --root {obeya-root} --require "<ids>" --proposed "<ids the proposed set covers>"`), and again once envelopes are in the obeya (drop `--proposed`).
6. **Review, then gate.** Before the user sees the full draft — and before autonomous mode approves its own — run every entry in `{workflow.finalize_reviewers}` over it (review skills as parallel subagents where available; file/text entries as standards to check), fold the findings in, and record material findings with their dispositions in the envelopes' Sequencing Notes. Then the epic quiz: present the reviewed set (title · depends-on · what it delivers · covers) and interrogate it — boundaries right? each epic one-dev-sized? every edge genuinely gating? anything to merge or split? Iterate until approved — per epic in guided mode, whole-set in quick mode, self-checked with outcomes recorded in Sequencing Notes in autonomous mode. **Nothing is written until the gate passes.** On approval: folders and envelopes written progressively as generated (ids via `uv run {skill-root}/scripts/ticket_tree.py next-id --root {workflow.project_root}`), each epic at `status: backlog` — slicing is not inception, and `ready` is claimed only when an epic's stories exist — then validate what was written (`... validate`) — fix anything it names before presenting.
6. **Review, then gate.** Before the user sees the full draft — and before autonomous mode approves its own — run every entry in `{workflow.finalize_reviewers}` over the `.drafts/` files (review skills as parallel subagents where available; file/text entries as standards to check), plus the resolvability check: open every path each envelope's references name; an entry that does not resolve on disk is a blocking finding. Fold the findings in and record material ones with their dispositions in the envelopes' Sequencing Notes — one line per finding, what changed or why declined; counts, scorecards, and process narrative never land in a ticket. Then the epic quiz: present the reviewed set (title · depends-on · what it delivers · covers) and interrogate it — boundaries right? each epic one-dev-sized? every edge genuinely gating? anything to merge or split? Iterate until approved — per epic in guided mode, whole-set in quick mode, self-checked with outcomes recorded in Sequencing Notes in autonomous mode. **Nothing reaches its `tickets/` place until the gate passes.** On approval: move the approved envelopes out of the drafts folder into their epic folders (ids via `uv run {skill-root}/scripts/ticket_tree.py next-id --root {obeya-root}`), clear their drafts, each epic at `status: backlog` — slicing is not inception, and `ready` is claimed only when an epic's stories exist — then validate what was written (`... validate`) — fix anything it names before presenting.
The envelope is co-authored working state from here on: a PM may enrich it directly — more detail, constraints, decisions — before inception. That detail is input the incept route reads, never drift to regenerate away.
@@ -17,7 +17,7 @@ The old artifact is a **view**, not a second source of truth:
1. Run the slice route to the epic set (its gate included).
2. On explicit confirmation, chain the incept route per epic — each epic through its own gate; quick and autonomous collapse the pauses per their mode rules.
3. Render the single file: `uv run {skill-root}/scripts/ticket_tree.py render --root {workflow.project_root} --out <path>/epics-and-stories.md`. The render is deterministic — statuses come straight from frontmatter — and carries a generated-file banner. Re-render after any change; hand-edits to the rendered file are overwritten, the tree wins. Say that when handing the file over.
3. Render the single file — the obeya's render query (local backing: `uv run {skill-root}/scripts/ticket_tree.py render --root {obeya-root} --out <path>/epics-and-stories.md`; v6 shapes are local-tree shapes, so a non-local backing hands this to its own reference). The render is deterministic — statuses come straight from frontmatter — and carries a generated-file banner. Re-render after any change; hand-edits to the rendered file are overwritten, the tree wins. Say that when handing the file over.
Anyone asking "give me one file to read or share" gets this render too — it is not only a v6 path.
@@ -30,6 +30,6 @@ If the user explicitly refuses the ticket tree, fall back to a single authored e
`sprint-status.yaml` stays in service for in-flight v6 work — and is **never used for net-new epics or stories**. Migration happens per epic, at the natural seam:
1. **In-flight epics finish where they are.** Stories already tracked in `sprint-status.yaml` run to done under the legacy flow. Never migrate half-built work.
2. **The next epic migrates by inception.** Its section in the v6 epics file is the input: write the envelope into the tree with fidelity — the v6 detail is co-authored input, never drift to regenerate away; `covers:` ids carried verbatim in the source scheme — then run the normal incept route against it.
2. **The next epic migrates by inception.** Its section in the v6 epics file is the input: write the envelope into the tree with fidelity — the v6 detail is co-authored input, never drift to regenerate away; `covers:` ids carried verbatim in the source scheme — then run the normal epic slice (inception) against it, materializing all files at once since v6 expects them up front.
3. **One story, one system.** Every story is tracked in exactly one place; the two systems never cross-reference by id. A tree story that genuinely waits on legacy in-flight work records that as prose in Dev Notes ("blocked until legacy story X merges"), never as a `depends_on` id the frontier can't verify.
4. **Name the seam once.** When a `sprint-status.yaml` is known to exist, say so at the start: in-flight v6 work is tracked there, everything new lives in the tree — `board` and `frontier` are the tracking from here on.
@@ -28,12 +28,12 @@ def ticket(tid, ttype, title, status="backlog", deps="[]", covers="[]", extra=""
f"risk: 2\ncreated: 2026-08-01\n---\n\n# {tid}\n")
def project_ticket(tid, title, status="backlog", extra=""):
def initiative_ticket(tid, title, status="backlog", extra=""):
"""The root node: no risk, no hitl, no depends_on, no covers."""
if "description:" not in extra:
extra += f'description: "{title} product"\n'
state = "" if "status:" in extra else f"status: {status}\n"
return (f"---\nschema: 1\nid: {tid}\ntype: project\ntitle: \"{title}\"\n"
return (f"---\nschema: 1\nid: {tid}\ntype: initiative\ntitle: \"{title}\"\n"
f"{state}{extra}created: 2026-08-01\n---\n\n# {tid}\n")
@@ -58,7 +58,7 @@ class TicketTreeTests(unittest.TestCase):
self.epic2_dir = self.tickets / "reporting"
self.epic_kids.mkdir(parents=True)
self.epic2_dir.mkdir(parents=True)
(self.root / "ticket.md").write_text(project_ticket("ALRT-1", "Alerting"))
(self.root / "ticket.md").write_text(initiative_ticket("ALRT-1", "Alerting"))
(self.epic_dir / "ticket.md").write_text(ticket(
"ALRT-3", "epic", "Alert rules", covers="[CAP-4]",
extra='description: "Rules people manage"\n'))
@@ -80,7 +80,7 @@ class TicketTreeTests(unittest.TestCase):
self.assertEqual(out["key"], "ALRT") # the root node's id prefix, not a key: field
self.assertEqual(out["id"], "ALRT-41")
def test_project_node_key_wins_over_ids_in_tree(self):
def test_initiative_node_key_wins_over_ids_in_tree(self):
# A stray foreign-keyed ticket never confuses the key: the root node owns it.
(self.tickets / "OTHER-9-stray.md").write_text(
ticket("OTHER-9", "task", "Stray"))
@@ -89,7 +89,7 @@ class TicketTreeTests(unittest.TestCase):
self.assertEqual(out["id"], "ALRT-41")
def test_rootless_tree_derives_key_from_ids(self):
# No project set: tickets sit straight in the output folder's tickets/.
# No initiative set: tickets sit straight in the root tickets/ bin of the work store.
with tempfile.TemporaryDirectory() as d:
root = Path(d)
(root / "tickets").mkdir()
@@ -155,7 +155,7 @@ class TicketTreeTests(unittest.TestCase):
outer = root / "tickets" / "outer"
inner = outer / "tickets" / "inner"
(inner / "tickets").mkdir(parents=True)
(root / "ticket.md").write_text(project_ticket("DEEP-1", "Deep"))
(root / "ticket.md").write_text(initiative_ticket("DEEP-1", "Deep"))
(outer / "ticket.md").write_text(ticket("DEEP-2", "epic", "Outer"))
(inner / "ticket.md").write_text(ticket("DEEP-3", "epic", "Inner"))
leaf = inner / "tickets" / "DEEP-4-deep-leaf.md"
@@ -179,7 +179,7 @@ class TicketTreeTests(unittest.TestCase):
self.assertEqual(counts["DEEP-3"], {"done": 1}) # progress is derived
self.assertEqual(counts["DEEP-2"], {}) # only direct leaves
self.assertEqual({n["id"]: n["type"] for n in out["nodes"]},
{"DEEP-1": "project", "DEEP-2": "epic", "DEEP-3": "epic"})
{"DEEP-1": "initiative", "DEEP-2": "epic", "DEEP-3": "epic"})
def test_frontier_dep_gating(self):
code, out = run("frontier", "--root", str(self.root))
@@ -214,7 +214,7 @@ class TicketTreeTests(unittest.TestCase):
self.assertEqual(states["ALRT-40"], "backlog") # childless
self.assertEqual(counts["ALRT-40"], {})
self.assertEqual(states["ALRT-1"], "backlog")
self.assertEqual(types["ALRT-1"], "project")
self.assertEqual(types["ALRT-1"], "initiative")
self.assertEqual(types["ALRT-3"], "epic")
self.assertEqual(out["blocked"][0]["id"], "ALRT-31")
self.assertEqual(out["leaf_totals"]["done"], 1)
@@ -295,11 +295,11 @@ class TicketTreeTests(unittest.TestCase):
self.assertEqual({e["file"] for e in out["errors"]},
{"tickets/reporting/ticket.md"})
(self.root / "ticket.md").write_text(
project_ticket("ALRT-1", "Alerting", extra="status: review\n"))
initiative_ticket("ALRT-1", "Alerting", extra="status: review\n"))
code, out = run("validate", "--root", str(self.root))
self.assertEqual(code, 1)
by_file = {e["file"]: e["error"] for e in out["errors"]}
self.assertIn("ready", by_file["ticket.md"]) # the project node too
self.assertIn("ready", by_file["ticket.md"]) # the initiative node too
def test_validate_node_missing_status_is_named(self):
(self.epic2_dir / "ticket.md").write_text(
@@ -310,22 +310,22 @@ class TicketTreeTests(unittest.TestCase):
self.assertIn("status", by_file["tickets/reporting/ticket.md"])
def test_validate_project_node_rejects_execution_fields(self):
# The project node scores nothing and traces nothing sideways.
# The initiative node scores nothing and traces nothing sideways.
(self.root / "ticket.md").write_text(
project_ticket("ALRT-1", "Alerting", extra="risk: 2\ncovers: [CAP-4]\n"))
initiative_ticket("ALRT-1", "Alerting", extra="risk: 2\ncovers: [CAP-4]\n"))
code, out = run("validate", "--root", str(self.root))
self.assertEqual(code, 1)
msgs = " | ".join(e["error"] for e in out["errors"])
self.assertIn("risk is not a project field", msgs)
self.assertIn("covers is not a project field", msgs)
self.assertIn("risk is not an initiative field", msgs)
self.assertIn("covers is not an initiative field", msgs)
def test_validate_project_node_only_at_the_root(self):
(self.epic_dir / "ticket.md").write_text(
project_ticket("ALRT-3", "Misplaced project"))
initiative_ticket("ALRT-3", "Misplaced initiative"))
code, out = run("validate", "--root", str(self.root))
self.assertEqual(code, 1)
msgs = " | ".join(e["error"] for e in out["errors"])
self.assertIn("project node may only sit at the tree root", msgs)
self.assertIn("initiative node may only sit at the tree root", msgs)
def test_validate_detects_cycle(self):
(self.epic_kids / "ALRT-13-rule-eval.md").write_text(
@@ -523,7 +523,7 @@ class TicketTreeTests(unittest.TestCase):
root = Path(d)
e = root / "tickets" / "one"
(e / "tickets").mkdir(parents=True)
(root / "ticket.md").write_text(project_ticket("NEW-1", "New"))
(root / "ticket.md").write_text(initiative_ticket("NEW-1", "New"))
(e / "ticket.md").write_text(ticket(
"NEW-2", "epic", "One", extra='description: "x"\nstatus: done\n'))
(e / "tickets" / "NEW-3-only.md").write_text(
@@ -58,7 +58,7 @@ created: 2026-08-01
PROJECT = """---
schema: 1
id: ALRT-1
type: project
type: initiative
title: "Alerting"
description: "The alerting product"
created: 2026-08-01
@@ -87,8 +87,8 @@ class UpdateTicketTests(unittest.TestCase):
self.epic_dir = self.tickets / "alert-rules"
self.epic_kids = self.epic_dir / "tickets"
self.epic_kids.mkdir(parents=True)
self.project = self.root / "ticket.md"
self.project.write_text(PROJECT, encoding="utf-8")
self.initiative = self.root / "ticket.md"
self.initiative.write_text(PROJECT, encoding="utf-8")
self.story = self.epic_kids / "ALRT-12-rule-crud.md"
self.story.write_text(STORY, encoding="utf-8")
self.bug = self.tickets / "ALRT-20-crash.md"
@@ -159,24 +159,24 @@ class UpdateTicketTests(unittest.TestCase):
self.assertEqual(code, 1)
self.assertIn("unknown status", out["error"])
def test_project_writable_fields_and_status_rule(self):
# A project scores nothing and traces nothing sideways.
def test_initiative_writable_fields_and_status_rule(self):
# An initiative scores nothing and traces nothing sideways.
for spec in ("risk=2", "hitl=true", "covers=CAP-4", "depends_on=ALRT-3"):
code, out = run("--path", str(self.project), "--set", spec)
code, out = run("--path", str(self.initiative), "--set", spec)
self.assertEqual(code, 1)
self.assertIn("not writable", out["error"])
code, out = run("--path", str(self.project), "--set", "owner=Mary")
code, out = run("--path", str(self.initiative), "--set", "owner=Mary")
self.assertEqual(code, 0)
self.assertIn("owner: Mary", self.project.read_text())
# The node lifecycle applies at every node altitude, project included.
code, out = run("--path", str(self.project), "--set", "status=review")
self.assertIn("owner: Mary", self.initiative.read_text())
# The node lifecycle applies at every node altitude, initiative included.
code, out = run("--path", str(self.initiative), "--set", "status=review")
self.assertEqual(code, 1)
self.assertIn("unknown status", out["error"])
code, out = run("--path", str(self.project), "--set", "status=in-progress")
code, out = run("--path", str(self.initiative), "--set", "status=in-progress")
self.assertEqual(code, 0)
code, out = run("--path", str(self.project), "--set", "status=done")
code, out = run("--path", str(self.initiative), "--set", "status=done")
self.assertEqual(code, 0)
self.assertIn("status: done", self.project.read_text())
self.assertIn("status: done", self.initiative.read_text())
def test_insert_and_replace_adjacent_lines_both_land(self):
# status is absent on the epic (insert after type:) while title is the
@@ -269,18 +269,17 @@ class UpdateTicketTests(unittest.TestCase):
self.assertEqual(code, 1)
self.assertIn("1-5", out["error"])
def test_hitl_raised_when_risk_crosses_threshold(self):
def test_hitl_never_derived_from_risk(self):
# hitl marks work a human must perform; risk changes never touch it.
low = self.tickets / "ALRT-50-low.md"
low.write_text(STORY.replace("id: ALRT-12", "id: ALRT-50")
.replace("risk: 3", "risk: 2")
.replace("hitl: true", "hitl: false"))
code, out = run("--path", str(low), "--set", "risk=4")
code, out = run("--path", str(low), "--set", "risk=5")
self.assertEqual(code, 0)
text = low.read_text()
self.assertIn("risk: 4", text)
self.assertIn("hitl: true", text)
def test_hitl_never_lowered_automatically(self):
self.assertIn("risk: 5", text)
self.assertIn("hitl: false", text)
code, out = run("--path", str(self.story), "--set", "risk=1")
self.assertEqual(code, 0)
self.assertIn("hitl: true", self.story.read_text())
@@ -35,8 +35,6 @@ import tempfile
import tomllib
from pathlib import Path
DEFAULT_HITL_THRESHOLD = 3
DEFAULT_TRANSITIONS = [
"backlog>in-progress",
"in-progress>review",
@@ -271,9 +269,6 @@ def main():
help='allowed node (initiative, epic) status moves as "from>to,from>to"; '
"defaults to the skill's customize.toml "
"node_lifecycle_transitions, else built-ins")
ap.add_argument("--hitl-threshold", type=int, default=None,
help="risk level at/above which hitl derives true; defaults to the "
"skill's customize.toml hitl_threshold, else 3")
ap.add_argument("--force", action="store_true",
help="apply a user-decided status move outside the transition graph; "
"unknown states are still refused")
@@ -289,12 +284,6 @@ def main():
if args.node_transitions is None:
cfg_node = cfg.get("node_lifecycle_transitions") or DEFAULT_NODE_TRANSITIONS
args.node_transitions = ",".join(str(t) for t in cfg_node)
hitl_threshold = args.hitl_threshold
if hitl_threshold is None:
ht = cfg.get("hitl_threshold", DEFAULT_HITL_THRESHOLD)
hitl_threshold = ht if isinstance(ht, int) and not isinstance(ht, bool) \
else DEFAULT_HITL_THRESHOLD
tree_ids, tree_deps = None, None
if args.path:
path = Path(args.path)
@@ -377,11 +366,8 @@ def main():
f"'{current}': {', '.join(legal) if legal else 'none (terminal)'}; "
"pass --force to apply a user-decided override")
# Derive hitl when risk is set without an explicit hitl: raise to true at the
# threshold, never lower automatically (an explicit --set hitl always wins).
if ticket_type not in NODE_TYPES and "risk" in updates and "hitl" not in updates:
if updates["risk"] >= hitl_threshold and fm.get("hitl") is not True:
updates["hitl"] = True
# hitl is never derived: it marks work a human must perform, set explicitly
# by a person (or the skill relaying one) via --set hitl=..., nothing else.
# depends_on validation.
if "depends_on" in updates:
@@ -53,7 +53,7 @@ When `ticket_file` is set it is the whole work definition, filling the role a `s
| Ticket | Build input |
|---|---|
| `title`, `## Context`, `## Behavior` (or `## Requirements`) | the clarified intent — what step-02 plans from. Already clarified by the ticket author: do not re-elicit what it answers |
| `## Acceptance Criteria` | the verify contract. Every `#n` becomes an acceptance criterion in `{spec_file}`, and its verify tail becomes a `## Verification` entry. These are the ticket author's, not yours — never drop, merge, or soften one; a criterion you cannot satisfy is a HALT, not a rewrite |
| `## Acceptance Criteria` | the verify contract. Every numbered criterion (a bold claim sentence with its `Verify:` line) becomes an acceptance criterion in `{spec_file}`, and its Verify line becomes a `## Verification` entry. These are the ticket author's, not yours — never drop, merge, or soften one; a criterion you cannot satisfy is a HALT, not a rewrite |
| `## Boundaries``Must not change:` | `Never:` in the spec's Boundaries & Constraints, as hard constraints: an implementation that violates one has failed even with every criterion green |
| `## Boundaries``May change:` | `Always:` — the authorized surface |
| `## References` | typed-document pointers (document type plus section, not paths). Resolve them against `{{.planning_artifacts}}` and load what the work actually needs — this replaces the freeform artifact scan in INSTRUCTIONS item 1B |
+5 -6
View File
@@ -269,18 +269,17 @@ class UpdateTicketTests(unittest.TestCase):
self.assertEqual(code, 1)
self.assertIn("1-5", out["error"])
def test_hitl_raised_when_risk_crosses_threshold(self):
def test_hitl_never_derived_from_risk(self):
# hitl marks work a human must perform; risk changes never touch it.
low = self.tickets / "ALRT-50-low.md"
low.write_text(STORY.replace("id: ALRT-12", "id: ALRT-50")
.replace("risk: 3", "risk: 2")
.replace("hitl: true", "hitl: false"))
code, out = run("--path", str(low), "--set", "risk=4")
code, out = run("--path", str(low), "--set", "risk=5")
self.assertEqual(code, 0)
text = low.read_text()
self.assertIn("risk: 4", text)
self.assertIn("hitl: true", text)
def test_hitl_never_lowered_automatically(self):
self.assertIn("risk: 5", text)
self.assertIn("hitl: false", text)
code, out = run("--path", str(self.story), "--set", "risk=1")
self.assertEqual(code, 0)
self.assertIn("hitl: true", self.story.read_text())
+2 -16
View File
@@ -35,8 +35,6 @@ import tempfile
import tomllib
from pathlib import Path
DEFAULT_HITL_THRESHOLD = 3
DEFAULT_TRANSITIONS = [
"backlog>in-progress",
"in-progress>review",
@@ -271,9 +269,6 @@ def main():
help='allowed node (initiative, epic) status moves as "from>to,from>to"; '
"defaults to the skill's customize.toml "
"node_lifecycle_transitions, else built-ins")
ap.add_argument("--hitl-threshold", type=int, default=None,
help="risk level at/above which hitl derives true; defaults to the "
"skill's customize.toml hitl_threshold, else 3")
ap.add_argument("--force", action="store_true",
help="apply a user-decided status move outside the transition graph; "
"unknown states are still refused")
@@ -289,12 +284,6 @@ def main():
if args.node_transitions is None:
cfg_node = cfg.get("node_lifecycle_transitions") or DEFAULT_NODE_TRANSITIONS
args.node_transitions = ",".join(str(t) for t in cfg_node)
hitl_threshold = args.hitl_threshold
if hitl_threshold is None:
ht = cfg.get("hitl_threshold", DEFAULT_HITL_THRESHOLD)
hitl_threshold = ht if isinstance(ht, int) and not isinstance(ht, bool) \
else DEFAULT_HITL_THRESHOLD
tree_ids, tree_deps = None, None
if args.path:
path = Path(args.path)
@@ -377,11 +366,8 @@ def main():
f"'{current}': {', '.join(legal) if legal else 'none (terminal)'}; "
"pass --force to apply a user-decided override")
# Derive hitl when risk is set without an explicit hitl: raise to true at the
# threshold, never lower automatically (an explicit --set hitl always wins).
if ticket_type not in NODE_TYPES and "risk" in updates and "hitl" not in updates:
if updates["risk"] >= hitl_threshold and fm.get("hitl") is not True:
updates["hitl"] = True
# hitl is never derived: it marks work a human must perform, set explicitly
# by a person (or the skill relaying one) via --set hitl=..., nothing else.
# depends_on validation.
if "depends_on" in updates: