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

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. 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 (scheduleddate_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 DoisCompleted: 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/<repo>/<effort>/, 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.