--- name: dev-tickets description: Storage model for development tickets, specs, and planning maps, which live in the Obsidian vault as TaskNotes tasks and are read and written with the `ticket` CLI. Use when creating an effort or spec, writing implementation or decision tickets, finding the frontier, or claiming, annotating, and closing a ticket — and whenever /to-spec, /to-tickets, /wayfinder, or an implementor reads or writes them. --- # Dev tickets Tickets, specs, and planning maps live in the Obsidian vault as TaskNotes tasks. They are read and written with **`ticket`**, a file-based CLI that works on the markdown directly — no running Obsidian, no HTTP API — so it behaves the same on every host. This skill owns the storage model: the shape on disk, the metadata, and the verbs that read and write it. The skills that produce tickets (`/to-spec`, `/to-tickets`, `/wayfinder`) own their own process and defer here for everything below. **Where the vault is** is what `ticket vault` prints. It is configured once per host, and the skill never names a path: ```sh ticket vault --set # once per host; refuses a directory that is not the vault ``` If `ticket` says no vault is configured, ask the user for the path and run that. If `ticket` itself is missing, the dotfiles install script installs it (`npm ci` inside `~/.local/lib/dev-tickets`); the launcher prints the exact command when its dependencies are absent. ## Vocabulary - **Vault** — the Obsidian vault, wherever it is on this host. The user-facing word for where tickets live. - **Collection** — the vault as mdbase sees it: `mdbase.yaml` plus the `_types/task.md` schema that TaskNotes maintains. Used only when talking about the tool's mechanics. - **Repo project** — the `[[repo]]` wikilink every effort in a repo shares. A note at that title is **optional**; see [Repo project notes](#repo-project-notes). - **Effort** — one feature or change. A task whose body is the planning map, which graduates into the spec. - **Decision ticket** — a subtask that answers a question. `/wayfinder`'s unit. - **Implementation ticket** — a subtask that builds something. `/to-tickets`' unit. - **Frontier** — the tickets that are open and not blocked. - **Title** — a note's filename stem. It is the note's identity (every edge is a wikilink to it) and is never a frontmatter key. - **Record** — what `--json` returns for a note: `path`, `title`, the derived `isBlocked`, then the frontmatter as it is on disk. `show --json` adds `body`. ## Shape ``` TaskNotes/Dev/atribot/ ├── atribot.md ← repo project note; optional, NOT a task └── Network isolation/ ├── Network isolation.md ← effort task ├── Should obsync share a namespace.md ← decision ticket └── Split obsync into its own container.md ← implementation ticket ``` One directory per repo, one per effort. Every note in an effort directory sits directly in it — the hierarchy is expressed by `projects`, not by nesting. `ticket create` owns this shape: it derives the directory and the parent link from `--repo` or `--effort`, so a note cannot be filed in the wrong place. **A title is an identity.** Every parent and blocking edge is a `[[wikilink]]` to a title, so titles are globally unique across the vault and a published note is never renamed. Give each one a name that reads on its own — `Split obsync into its own container`, not `02-split-container`. Uniqueness is **case-insensitive and vault-wide**, and it binds whether or not you created a note for the title: `projects: [[obsidian]]` would attach every ticket in that repo to a personal `Obsidian.md` anywhere in the vault. `ticket create` refuses a title that any note already carries, and `ticket show ` is the pre-check when choosing one (exit 2 means the title is free). ## Frontmatter `ticket create` writes every one of these in a single call. The middle column names what sets it. | Field | Set with | Values | |---|---|---| | `status` | `--status` at creation, then `claim` / `close` | `Open` · `In Progress` (claimed) · `Done` (complete, resolved) · `Wont Do` (abandoned, out of scope) | | `contexts` | `--context` on an effort; tickets inherit the effort's | from the repo's `CLAUDE.local.md` | | `projects` | derived: `--repo R` → `[[R]]`, `--effort E` → `[[E]]` | effort → `[[repo]]` · ticket → `[[effort]]` | | actor tag | `--actor agent\|human` at creation; `close --actor` to correct it | `agent-step` · `human-step` — exactly one, durable, survives completion | | `needs-info` | `stall` / `unstall` | tag, present only while stalled on an unanswered question | | `blockedBy` | `--blocked-by` (repeatable) at creation; `set blockedBy+=` / `-=` later | list of `{uid, reltype}`; `reltype` is `FINISHTOSTART`; every blocker must resolve to an existing task | | `ticket_type` | `--type` | `research` · `prototype` · `grilling` · `setup` · `implementation` | | `git_branch` | `set git_branch=…` on the effort | effort tasks only; omit entirely when on main/master | | `git_worktree` | `set git_worktree=…` on the effort | effort tasks only; omit entirely when the main worktree | | `agent_session` | `claim` | `$CLAUDE_CODE_SESSION_ID`, overwritten each session that works the ticket | The tool writes the rest itself: the `task` tag, `priority` (the vault's default), `date_created` and `date_modified` (bumped on every write), and `date_completed` at close. It never writes `date_scheduled` or a `title` key. A ticket on disk reads: ```yaml --- status: Open priority: 2-Normal contexts: - Obsidian projects: - "[[Network isolation]]" date_created: 2026-08-10T09:12:44.118-07:00 date_modified: 2026-08-10T09:12:44.118-07:00 blockedBy: - uid: "[[Verify internal network publish]]" reltype: FINISHTOSTART tags: - task - agent-step ticket_type: implementation agent_session: 444e4dcb-de76-4f6b-8c39-7d08d6aa9f5f --- ``` Quoting varies by writer — `ticket` single-quotes wikilinks and dates, TaskNotes double-quotes and leaves them bare — and both are valid YAML; do not "fix" it. `blocked` is not a status. `isBlocked` is derived from `blockedBy` and the live status of each blocker (TaskNotes does the same on the desktop), so blocking is recorded once, in `blockedBy`, and read back from `isBlocked` in every record. A blocker that does not resolve warns and does not block. `ticket_type: setup` is groundwork that unblocks a decision — provisioning access, moving data — whose *result* later tickets depend on. It corresponds to `/wayfinder`'s "task" type. ## Body Effort task — the map first, and the spec replacing `## Destination` once the way is clear. `/wayfinder` and `/to-spec` each own their own section content. Ticket — `## Question` for a decision ticket, `## What to build` plus acceptance checkboxes for an implementation ticket. A resolved decision ticket gains `## Answer`. Every effort and ticket task takes the same two trailing sections, which `ticket create` appends when the body lacks them: ```markdown ## Notes ### [[2026-08-10]] Squid rejects CONNECT to the registry on first run; the allowlist needs the CDN host too, not just the API host. ## As Built ``` `## Notes` is a running log — hurdles, surprises, design changes — appended **while the work happens** with `ticket note`, under a sub-heading wikilinking the current date. `## As Built` is written at completion with `ticket as-built`, and only when the implementation materially deviated from what the ticket asked for. ## Repo configuration Each repo's `CLAUDE.local.md` carries the context to stamp on its notes: ```markdown ## Dev tickets - context: Obsidian - repo: atribot # optional; defaults to the directory name ``` When a repo has no such block, ask the user for the context and offer to write the block. A guessed context files the effort under the wrong area and stays wrong. ## Creating An effort names its repo and context; the tool files it at `TaskNotes/Dev/<repo>/<title>/<title>.md` with `projects: [[repo]]` and no actor tag: ```sh ticket create --repo atribot --title 'Network isolation' --context Obsidian \ --set git_branch=feat/network-isolation \ --body-file - <<'BODY' ## Destination ... BODY ``` A ticket names its effort; the tool files it beside the effort note with `projects: [[effort]]`, the effort's contexts, the actor tag, and `ticket_type`: ```sh ticket create --effort 'Network isolation' --title 'Split obsync into its own container' \ --type implementation --actor agent \ --blocked-by 'Verify internal network publish' --blocked-by 'Provision the registry mirror' \ --body-file - <<'BODY' ## What to build - [ ] Move the compose service into its own namespace BODY ``` `--type` and `--actor` are required for a ticket and refused for an effort; `--context` is the reverse. `--blocked-by` accepts bare or `[[bracketed]]` titles and every one must exist — a typo'd blocker would never block, so it is an error instead. Create in dependency order. `--set k=v` adds any other field (`git_worktree`, `timeEstimate`); `--json` prints the record. Creation refuses — and writes nothing — when the target file exists or any note in the vault has the same title case-insensitively (exit 2). Resolving a collision is the user's choice. ### Repo project notes Optional, and there is no need to create one. `projects: [[atribot]]` groups the repo's efforts whether or not a note by that name exists — queries match on the frontmatter value, so a dangling link queries exactly like a resolved one. Create one only to give a repo a landing page. Its single job is to be the note Obsidian's Subtasks view renders on: `TaskNotes/Views/relationships.base` matches a task's `projects` against `this.file`, so with no note there is no page to see the effort list on. Nothing reads the note's own frontmatter — it is not a task, so TaskNotes skips it — which makes the body free-form. A description and the repo path is enough: ```markdown Matrix bot and its companion services, deployed as compose stacks. Repo: `/home/tgrosinger/code/atribot` ``` `ticket` only makes tasks. Write the file directly at `TaskNotes/Dev/<repo>/<repo>.md`. ## Routing tags Two independent axes, neither of which is a status. `status` carries the lifecycle; these carry *who does this step* and *whether it can proceed*. **Actor — `agent-step` or `human-step`.** Exactly one on every ticket, from creation onward. On an open ticket it names the intended next actor; on a closed ticket it records the actor who actually completed the work. It is durable: claiming and closing do not remove it, so a `Done` ticket still records whose step it was. Effort tasks carry no actor tag; only tickets do. **Stall — `needs-info`.** Transient and orthogonal to the actor. It means the assigned actor cannot proceed without information from someone else; it does not reassign the ticket. Thus `agent-step` + `needs-info` means *agent work awaiting a human answer*, while `human-step` + `needs-info` means *human work awaiting information from another person or source*. Add it the moment work stalls, remove it when the question is answered. The actor usually follows from `ticket_type`, and `/wayfinder` calls the same distinction HITL vs AFK: | `ticket_type` | actor | why | |---|---|---| | `research` · `implementation` | `agent-step` | an agent drives it alone | | `grilling` · `prototype` | `human-step` | resolves only through live exchange with the user | | `setup` | either | depends on whether the agent can do the work itself | `setup` is the case that must be decided per ticket — which is exactly why the actor is a tag and not derived from `ticket_type` at query time. | Transition | `status` | actor tag | `needs-info` | verb | |---|---|---|---|---| | Created | `Open` | set, exactly one | absent | `create --actor` | | Claimed | → `In Progress` | unchanged | unchanged | `claim` | | Stalled on a question | unchanged | unchanged | **add** | `stall` | | Question answered | unchanged | unchanged | **remove** | `unstall` | | Reassigned to the other actor | unchanged | **swap** | unchanged | `set tags+=… tags-=…` | | Completed | → `Done` | **swap if the other actor did the work** | must be absent | `close [--actor]` | | Abandoned | → `Wont Do` | unchanged | remove if present | `close --wont-do` | The verbs enforce the two rules that used to be easy to forget: `close` always clears `needs-info`, and `close --actor human` (or `agent`) corrects the actor tag when the other actor finished the work — which is what keeps `agent-step AND status:Done` a truthful record of what agents actually shipped. `set tags+=needs-info` and friends still work as an escape hatch, with a warning naming the verb that would have done it properly. ## Working a ticket 1. **Claim it** before any work, so a concurrent session skips it: ```sh ticket claim 'Split obsync into its own container' ``` The session id comes from `$CLAUDE_CODE_SESSION_ID` (or `--session`). `claim` refuses a ticket that is blocked or already `In Progress` under another session; `--force` overrides both. 2. **Record the checkout** on the *effort* task. When the branch is not `main` or `master`, set `git_branch`. When working in a linked worktree rather than the repository's main worktree, also set `git_worktree` to the absolute root returned by `git rev-parse --show-toplevel`: ```sh ticket set 'Network isolation' git_branch=feat/network-isolation git_worktree=/home/tgrosinger/code/atribot-ni ``` Omit `git_branch` on `main`/`master`, and omit `git_worktree` in the main worktree (`ticket set '<effort>' git_worktree=` removes it). If the checkout changes later, update both fields. 3. **Append notes as you go**; each lands under today's `### [[YYYY-MM-DD]]` heading (reused within a day), above `## As Built`: ```sh ticket note 'Split obsync into its own container' 'Squid rejects CONNECT to the registry on first run.' ticket note 'Split obsync into its own container' --file - # longer text on stdin ``` 4. **Stall it** if you hit a question only the user can answer — the tag and the logged question are one write: ```sh ticket stall 'Split obsync into its own container' 'Should the registry mirror share the proxy namespace?' ticket unstall 'Split obsync into its own container' 'Yes — same namespace.' ``` Leave `status` at `In Progress` — the ticket is still yours, it just cannot proceed. 5. **Close it.** Write `## As Built` first if the implementation deviated, then: ```sh ticket as-built 'Split obsync into its own container' 'Kept the socket; a namespace split was not needed.' ticket close 'Split obsync into its own container' # Done ticket close 'Split obsync into its own container' --actor human # the human finished it ticket close 'Split obsync into its own container' --wont-do # abandoned ``` `close` stamps `date_completed` and clears `needs-info`. Closing an **effort** is refused while any of its tickets is still open — close or `--wont-do` them first; it never cascades. Closing a ticket unblocks its dependents automatically; there is nothing to update on them. To resume a ticket: `cd` to its effort's `git_worktree`, then `claude --resume <agent_session>`. When `git_worktree` is absent, use the repository's main worktree: the repo project note records its path when that note exists; otherwise resume from an existing checkout of that repo and use its root from `git rev-parse --show-toplevel`. ## Querying Every reference (`REF`, `EFFORT`) is a vault-relative path or a title — bare or `[[bracketed]]`, case-insensitive. An ambiguous title is refused with the candidates listed. The **frontier** of one effort — open and unblocked — and the **agent frontier**, what an agent may pick up right now (also `agent-step` and not stalled): ```sh ticket frontier 'Network isolation' ticket frontier 'Network isolation' --actor agent --json ``` **Awaiting a human** — both axes reach the human, so the inbox is their union, minus completed work, across the whole vault: ```sh ticket inbox ``` Everything else is `list`, scoped to `TaskNotes/Dev/` unless `--all`; the filters AND together: ```sh ticket list --effort 'Network isolation' # every ticket of the effort, any status ticket list --repo atribot --open # the repo's open efforts ticket list --open --where 'status == "In Progress"' ticket list --all --where 'tags.contains("human-step") && exists("date_completed")' ``` `--where` is mdbase's expression language, not CEL: `==`, `!=`, `&&`, `||`, `!`, `.contains("x")`, `exists("field")`, `file.name`, indexing. `in` and CEL macros (`exists(t, …)`) are rejected as invalid expressions — the tool reports the error rather than returning nothing. Text output is one row per note — status (with `blocked` / `needs-info` flags), actor, title, path. `--json` prints records; pipe to `jq`: ```sh ticket frontier 'Network isolation' --actor agent --json | jq -r '.[0].path' ``` ## Cheatsheet ``` ticket vault [--set PATH] print the vault path, or record it for this host ticket show REF print a note verbatim (--json: its record with body) ticket list [--effort E] [--repo R] [--open] [--where EXPR] [--all] tasks under TaskNotes/Dev (or the whole vault with --all) ticket frontier EFFORT [--actor agent|human] the effort's tickets that are Open and unblocked ticket inbox human-step or needs-info, not completed, vault-wide ticket create --repo R --title T --context C [--set k=v] [--body-file F|-] ticket create --effort E --title T --type TYPE --actor agent|human [--blocked-by B]... [--body-file F|-] ticket claim REF [--session ID] [--force] In Progress under this session ticket note REF TEXT|--file F [--date YYYY-MM-DD] append to ## Notes, above ## As Built ticket as-built REF TEXT|--file F append under ## As Built ticket stall REF TEXT|--file F add needs-info and log the question ticket unstall REF [TEXT|--file F] clear needs-info, optionally logging the answer ticket close REF [--wont-do] [--actor agent|human] Done (or Wont Do), date_completed, needs-info cleared ticket set REF k=v k= k+=v k-=v ... edit frontmatter; += / -= on tags, contexts, projects, blockedBy global: --vault PATH --json --help ``` Exit codes: `1` usage or error, `2` refused (collision, blocked, ambiguous, not found, open children, not a task), `3` the file changed between read and write — nothing was written; re-run the command. ## Guardrails - **Create a new ticket rather than renaming a published one.** Every inbound `projects` and `blockedBy` edge points at the title. - **Body by hand is fine; frontmatter goes through `ticket`.** `set` and the verbs stamp `date_modified`, refuse non-tasks, keep the schema's types, and refuse to clobber a file another writer changed underneath them. A direct frontmatter edit does none of that. - **There is no delete.** `ticket` never removes a note, and neither should you without the user's explicit say-so. - `ticket` does not talk to Obsidian. When Obsidian is running on the same host it picks the change up like any external edit; when it is not, sync carries it. Why the model is shaped this way, and what was rejected on the way here: [README.md](./README.md).