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.
19 KiB
name, description
| name | description |
|---|---|
| dev-tickets | 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:
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.yamlplus the_types/task.mdschema 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. - 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
--jsonreturns for a note:path,title, the derivedisBlocked, then the frontmatter as it is on disk.show --jsonaddsbody.
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:
---
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:
## 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:
## 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:
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:
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:
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
- Claim it before any work, so a concurrent session skips it:
The session id comes from
ticket claim 'Split obsync into its own container'$CLAUDE_CODE_SESSION_ID(or--session).claimrefuses a ticket that is blocked or alreadyIn Progressunder another session;--forceoverrides both. - Record the checkout on the effort task. When the branch is not
mainormaster, setgit_branch. When working in a linked worktree rather than the repository's main worktree, also setgit_worktreeto the absolute root returned bygit rev-parse --show-toplevel:Omitticket set 'Network isolation' git_branch=feat/network-isolation git_worktree=/home/tgrosinger/code/atribot-nigit_branchonmain/master, and omitgit_worktreein the main worktree (ticket set '<effort>' git_worktree=removes it). If the checkout changes later, update both fields. - Append notes as you go; each lands under today's
### [[YYYY-MM-DD]]heading (reused within a day), above## As Built: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 - Stall it if you hit a question only the user can answer — the tag and the logged question are one write:
Leave
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.'statusatIn Progress— the ticket is still yours, it just cannot proceed. - Close it. Write
## As Builtfirst if the implementation deviated, then: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 # abandonedclosestampsdate_completedand clearsneeds-info. Closing an effort is refused while any of its tickets is still open — close or--wont-dothem 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):
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:
ticket inbox
Everything else is list, scoped to TaskNotes/Dev/ unless --all; the filters AND together:
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:
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
projectsandblockedByedge points at the title. - Body by hand is fine; frontmatter goes through
ticket.setand the verbs stampdate_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.
ticketnever removes a note, and neither should you without the user's explicit say-so. ticketdoes 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.