Create a new `ticket` javascript executable which wraps https://github.com/callumalpass/mdbase-tasknotes and adds all the operations required for managing tickets in the vault.
305 lines
19 KiB
Markdown
305 lines
19 KiB
Markdown
---
|
|
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 <vault path> # 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 <title>` 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).
|