Files
tgrosinger 4ccc9e6365 TaskNotes: Remove tn and tasknotes-cli skill and replace with custom script
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.
2026-08-28 09:56:16 -07:00

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).