# Why dev-tickets is shaped this way `SKILL.md` is the what and the how. This is the why — the constraints that forced each choice, the alternatives rejected, and the vault settings the model quietly depends on. Read it before changing the model; most of the obvious simplifications have already been tried on paper and don't survive contact with the tooling. Designed 2026-08-10, replacing per-repo `.scratch/` directories. Revised 2026-08-28: the model is unchanged, but the mechanics moved from `tn` + `obsidian` to the file-based `ticket` CLI — see [2026-08-28: file-based on every host](#2026-08-28-file-based-on-every-host). The sections between here and there describe the constraints as they stood on the desktop with Obsidian running; where they talk about `tn` or `obsidian`, that is history. ## The goal Everything about a piece of work — the idea, the planning, the spec, and the implementation tickets — in one place, queryable, with real dependency edges instead of prose cross-references like "that is ticket 03". ## The constraints that shaped it These are properties of the tools, not preferences. Most of the design falls out of them. **Grouping rides on `projects`, not on folders.** `path` is filterable, so directory layout *could* scope a query — but archiving moves a note to `TaskNotes/Archive/` and changes its path, while its `projects` edges survive. Folders organise notes for a human; `projects` is what a query can trust. **`tn update` cannot write a task's body.** There is no `details` flag on update, so a body can only be set at creation — which `tn create --details-file` now does. Editing an existing body still goes through `obsidian`. **Three CLI gaps were closed rather than worked around** (2026-08-11, effort `tn API feature gaps`): `--add-tags`/`--add-contexts`/`--add-projects` were silent no-ops that returned 200 with the task unchanged; `blockedBy` was invisible to both `tn update` and `--filter`; and `PUT {"blockedBy": []}` could not clear the field, because the deletion pass fired only on a literal `undefined` that JSON cannot express. The plugin gained `POST /api/tasks/:id/{tags,contexts,projects,dependencies}` and the CLI was wired to them. The two-CLI split below survives this, but shrank to what `tn` genuinely does not model. **Subtasks are `projects`.** There is no parent field anywhere in TaskNotes. A task is a subtask of whatever its `projects` array wikilinks to, and the parent can be any note — including one that doesn't exist. The vault's own `TaskNotes/Views/relationships.base` defines its Subtasks view exactly this way. **`isBlocked` is derived.** The plugin maintains a dependency index and computes `isBlocked` from `blockedBy` plus the live status of each blocker, exposing it as a read-only field on every task. Storing a `blocked` status alongside it would be a second source of truth that drifts. ## Choices, and what lost **Vault over `.scratch/`.** `.scratch/` worked from a plain filesystem with no daemon; the vault needs Obsidian running. That's a real availability regression, accepted because a single location for idea → planning → implementation was the whole point. Moving creation to `tn create` (2026-08-11) deepened it. Tickets used to be written straight to disk, so a stopped Obsidian cost only status updates; now it costs creation as well. Reading a ticket never depended on Obsidian and still doesn't — the notes are plain markdown on disk. The trade was accepted because hand-authoring had to reproduce the plugin's `fieldMapping` (`scheduled` → `date_scheduled`) by hand, and silently produced a note the plugin then rewrote. **The spec and the planning map are one note.** They were briefly separate — a plain "effort note" holding the wayfinder map, with the spec as a task beneath it — because a map isn't naturally a task. That split existed only to give the map a non-task home, and it cost a query: with tickets two levels down, no single `projects:contains` reached the whole effort. Collapsing them is also closer to the truth, since the map's Destination is what graduates into the spec. The cost is that decision and implementation tickets become siblings with no structural distinction, which `ticket_type` now carries instead. **Rejected: dual-parenting.** Giving implementation tickets both `[[Spec]]` and `[[Effort]]` would make one query span everything, but it overloads `projects` to mean both "hierarchy parent" and "effort membership" — the effort's Subtasks view would flatten the spec and every ticket into one list. **Rejected: a tag per repo and per effort.** `tags:contains:"effort/network-isolation"` scopes at any depth and would have worked, but it creates unbounded tag vocabulary that duplicates the directory path. The `[[repo]]` wikilink gets the same result with the mechanism already in use. **Repo project notes are optional** (revised 2026-08-11). They were originally specified as one plain note per repo. Two findings demoted them. Grouping never needed the note: `projects` matches on the frontmatter value, so a dangling `[[repo]]` queries identically to a resolved one — `[[tasknotes-cli]]` had been running that way unnoticed. And the documented `projects: []` frontmatter was inert, since the Subtasks filter opens with `file.hasTag("task")` and a project note deliberately has no such tag, so nothing ever read it. What survives is narrower and worth keeping: the note's *path* is what `relationships.base` compares `this.file` against, so a repo with no note has no page to view its efforts on. That makes the note a landing page, created on demand, with a free-form body. It still must not be a task — one there would show up in `tn list` as something to do. **The link binds without a note.** Making the note optional does not make an unmatched `[[repo]]` inert: Obsidian resolves case-insensitively across the whole vault, so `[[obsidian]]` attaches the repo's tickets to a personal `Obsidian.md` at the vault root and lists them in *its* Subtasks view. Queries stay correct throughout, which is why it goes unnoticed. Title collisions are checked by looking, with `-iname`. **Tags for routing, a field for type.** The routing tags sit on the note as tags; `ticket_type` is a registered user field instead, because TaskNotes rewrites frontmatter on update and an unregistered key isn't guaranteed to survive the round-trip. **Actor and stall are two axes, not one** (revised 2026-08-11). The original vocabulary was `needs-info` / `ready-for-agent` / `ready-for-human` — three mutually exclusive values answering *who acts next*. That framing made every value transient: claiming or closing a ticket should logically have cleared or rewritten the tag, and none of the transitions were written down. The first six real tickets all reached `Done` still carrying `ready-for-agent`, which under that reading is drift. Renaming the actor values to `agent-step` / `human-step` changes the question the tag answers from "who is queued next" to "whose step is this", which is **durable** — true at creation and still true after completion, where it becomes a record of who did the work. That makes the observed behaviour correct rather than sloppy, and it costs nothing to maintain. `needs-info` does not belong on that axis: it is not an actor, it is a stall. It became an orthogonal flag, so `agent-step` + `needs-info` reads as "agent work, currently stalled on a question" — a state the old mutually-exclusive vocabulary could not express at all without lying about who owned the ticket. The one maintained rule is that the actor tag is **corrected at close** if the other actor did the work. Pure write-once was considered and rejected: it makes `agent-step AND status:Done` mean "was scoped for an agent" rather than "an agent shipped it", and the second is the question worth being able to ask. The full transition table is in `SKILL.md`; it exists because its absence was the single largest source of uncertainty in the model. **`setup` instead of wayfinder's `task`.** Upstream's fourth type is `task`, which collides twice here: with `implementation` (both mean "do work") and with the `task` tag that makes a note a task at all. `setup` names what the type is for — groundwork whose result later tickets depend on. **Statuses collapse, nothing is archived.** Upstream ran two deliberately separate vocabularies: `blocked`/`needs-info`/`ready-for-agent`/`ready-for-human`/`wont-do`/`complete` for implementation tickets, and `claimed`/`resolved`/`out-of-scope` for decision tickets. Both fold onto the same four TaskNotes statuses. Archiving would have been the natural home for `wont-do`, but it moves the file to `TaskNotes/Archive/` and breaks the directory layout — hence the `Wont Do` status. **Rejected: `curl`.** `PUT /api/tasks/:id` has no validation and accepts `blockedBy` and `projects` directly, which made it the obvious escape hatch until the Obsidian CLI turned out to cover the same ground with no auth token and no JSON assembly. **Rejected: MCP.** The plugin ships an MCP server, currently disabled. Its `create_task` and `update_task` tools validate through a zod schema that has no `blockedBy` key, so an extra field is *stripped* before the handler sees it — strictly worse than the unvalidated HTTP route. ## Vault settings this depends on Invisible from `SKILL.md`, and the model breaks quietly without them. - **mdbase export enabled** — TaskNotes maintains `mdbase.yaml` and `_types/task.md` in the vault root. `ticket` opens the vault through that schema and refuses to run without a `task` type; the completed statuses (`tn_completed_values`) come from it, not from code. - **Status `Wont Do`** — `isCompleted: true` so it drops off the frontier and `ticket` treats it as completed, `excludeFromCycle: true`, `autoArchive: false`. - **Status `Done` with `autoArchive: false`** — changed from `true` for this system. Auto-archiving would move completed tickets out of their effort directory. Note this is global: personal completed tasks no longer move to `TaskNotes/Archive/` either. - **User fields** `ticket_type`, `git_worktree`, `git_branch`, `agent_session` — all `text`, hidden from the creation and edit modals via `modalFieldsConfig`, since they're agent-written. Registering them is what puts them in `_types/task.md`, which is the only reason a file-based writer can be schema-faithful. - **`taskIdentificationMethod: tag`** with `taskTag: task` and empty `excludedFolders` — this is what lets tickets live under `TaskNotes/Dev/` instead of `tasksFolder`. Switching identification to folder-based would orphan every dev ticket at once. ## Confirmed against a running vault The three behaviours derived from source at design time were observed on 2026-08-11: 1. `tn list --json` does include `isBlocked` on every task, so the frontier query works as written. 2. `tn update --add-tags` was a silent no-op, exactly as suspected — and is now fixed. 3. `obsidian property:set` and `obsidian eval` behave as the app bundle suggested. One behaviour was *not* anticipated: `obsidian append` writes only at end of file, so a `## Notes` entry lands under the trailing `## As Built` heading instead. `SKILL.md` gives the working form. Probing `POST /api/tasks` the same day settled how creation works. The endpoint accepts `details` and `customProperties` — which is what made `tn create` able to replace hand-authoring — but **silently ignores** `folder`, `folderPath`, and `path`, filing every new note into `tasksFolder` regardless. `tn create --folder` therefore creates and then moves. Obsidian's watcher re-indexes the move immediately, and wikilinks resolve by basename, so nothing breaks; the note is seconds old and has no inbound links yet. ## Deferred - The four live `.scratch/` directories (`atribot`, `claude-review`, `gnucash-visualizations`, `pinball-datasette`) stay where they are; new efforts use this system. Completed work isn't worth migrating, and the in-flight `atribot` tickets carry prose cross-references that need rewriting by hand. - Making `POST /api/tasks` honour a folder would let `tn create --folder` drop its post-create move. The move is a workaround for a server-side gap, and it is the one step that touches the vault outside the plugin. - `tn update` has no `--prop`, so custom properties still need `obsidian property:set` after creation. Adding it would retire the second CLI from the ticket workflow entirely. ## 2026-08-28: file-based on every host ### The constraint that changed The skill assumed the desktop: a hardcoded vault path under `~/Documents`, `tn` (which needs the TaskNotes HTTP API inside a running Obsidian) and `obsidian` (which needs the desktop app). On tachi none of that exists — the vault is the obsync container's headless-sync mirror at another path, with no Obsidian process — so a session there could not create, claim, annotate, or close a ticket at all. The "vault over `.scratch/`" trade above had accepted needing Obsidian; a second host made that unacceptable. ### What replaced it One file-based tool, `ticket`, over `@callumalpass/mdbase` — the same library and `_types/task.md` schema that TaskNotes' mdbase export already maintains in the vault. It reads and writes the markdown directly, so it behaves identically wherever the vault is on disk and whether or not Obsidian is running. The only per-host fact is the vault path, recorded once with `ticket vault --set` in `~/.config/mdbase-tasknotes/config.json` — the same file and key `mtn` would use, so the two could never disagree — and resolved as flag → `MDBASE_TASKNOTES_PATH` → that file, with no current-directory fallback. The model, the shape, the routing tags and the transition table are unchanged; only the mechanics moved, and the verbs enforce the rules the prose used to ask agents to remember (needs-info cleared at close, actor corrected at close, blockers must exist, efforts refuse to close over open tickets). The package lives in dotfiles at `~/.local/lib/dev-tickets` (plain ESM JavaScript, `node --test`, mdbase the only dependency). There is no shell shim: the entry point's shebang is `#!/usr/bin/env -S MISE_NODE_VERSION=lts node`, which runs it under mise's lts Node from inside any repo regardless of that repo's pin, needs only mise's shims directory on PATH, and resolves `node_modules` from the file's real path so the stow symlink is transparent. ### Facts measured against mdbase 0.2.2 and the live vault - mdbase's `update` rewrites the entire frontmatter in its own style: single-quoted wikilinks *and* dates where TaskNotes double-quotes and leaves them bare, trailing blank lines dropped, type defaults applied on read but never written. All valid YAML; TaskNotes rewrites in its own style when it next touches the note. `priority` is therefore written explicitly, and keys are emitted in TaskNotes' order, so tool-written notes look native. - mdbase does not stamp the type's `generated:` fields for a v0.2 type. The tool stamps `date_created` / `date_modified` in TaskNotes' local-offset shape itself. - mdbase's concurrency check covers only its own stat→write window inside `update()`. The tool records the mtime when it reads, compares in mdbase's `preWriteHook`, and refuses the write (exit 3) if the file changed — no retry; every verb is idempotent and re-running is the recovery. On tachi that guard is the only coordination with the atribot agent container sharing the vault copy, deliberately. - `in` in `--where` errors with `invalid_expression` through the library; the earlier belief that it "silently returns nothing" was `mtn` swallowing the error. - On the live vault (3,859 notes, 247 tasks): open ~50 ms, all tasks ~0.6 s, one effort ~20 ms. - `.mdbase/cache.sqlite` in the vault root is a local artifact; it never appears in the sync log, while `mdbase.yaml` does. - Vault-wide, case-insensitive title uniqueness is now enforced by `create`, not checked by hand; an ambiguous blocker (two notes with the same title) is treated like an unresolvable one — warned about and ignored — which surfaces the collision instead of guessing which note Obsidian would pick. ### What lost - **A second host-side sync device, writes through the container, and ACLs.** The mirror at `/tachi/docker/atribot/vault` became user-owned instead; ZFS there is mounted `noacl`, so ACLs were never on the table. - **`mtn` (mdbase-tasknotes) as the tool.** Its `create` is natural-language only and files into the default tasks folder; its `update` cannot touch projects, blockers, custom properties, or the body; its JSON carries no `isBlocked`. A wrapper was needed regardless, so the wrapper is the whole tool. It is not installed even as the writer of the config file — `ticket vault --set` covers that — and nothing is contributed upstream. - **A shell shim** (the `tn` pattern). The shebang does the same job with no file to maintain; `tn` already ran off-pin (`installs/node/lts` was not the version pinned in `config.toml`), so the shim bought nothing the shebang does not. - **`--folder` and `--project` on create.** The tool owns `TaskNotes/Dev///`, so `--repo` / `--effort` derive both, plus the effort's contexts for a ticket, and the effort/ticket flag validation falls out of that. - **Auto-retry on a concurrent modification.** A retry that silently re-applied `close` over someone else's edit is exactly the clobber the guard exists to prevent. - **Cascading effort close.** Refusing while children are open makes the human decide what to abandon. - **An effort-agnostic frontier.** An agent always works one effort; the wider question is a `list --where`. - **A `CONTEXT.md` and a `docs/adr/`.** `SKILL.md`'s Vocabulary is the glossary and this README is the decision log; a second copy of either would drift. - **Detecting stale `In Progress` claims** from crashed sessions. Still a `list --where` question, not a model change. Acceptance was dogfooding: this effort's own tickets were hand-written (nothing existed yet to write them), and each lifecycle step moved to `ticket` the moment the verb existed.