Compare commits

...
9 Commits
Author SHA1 Message Date
tgrosinger 12333cc485 Claude: Add allowed write dirs for voyager 2026-08-31 20:16:29 -07:00
tgrosinger 144c1a204b Neovim: Update plugins 2026-08-31 20:16:11 -07:00
tgrosinger 2b9a7b80b9 Neovim: Install Claude Review from git instead of pulling locally 2026-08-31 20:16:07 -07:00
tgrosinger f8d4225429 Skills: Add show-me skill 2026-08-31 20:15:09 -07:00
tgrosinger b393048467 Skills: Fix name of matt-code-review 2026-08-28 13:42:36 -07:00
tgrosinger b1e8fa1c14 Claude: Add missing status line script 2026-08-28 11:35:00 -07:00
tgrosinger b31b88b3c1 Install: Add socat on the server 2026-08-28 09:56:35 -07:00
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
tgrosinger ca4d74cd60 Skills: Add daily note cleanup and weather skills 2026-08-27 09:52:53 -07:00
37 changed files with 3286 additions and 396 deletions
+3
View File
@@ -6,3 +6,6 @@
# Python # Python
*.pyc *.pyc
# dev-tickets CLI dependencies, installed by install-packages.sh (npm ci)
home/.local/lib/dev-tickets/node_modules/
@@ -0,0 +1,122 @@
---
name: daily-note-cleanup
description: Tidy a day's Obsidian daily note — create that day's note and the next day's from the template when missing, fill in the weather, and other cleanup tasks. Use when asked to clean up, tidy, or process yesterday's or a given day's daily note, or on a scheduled end-of-day run.
---
# daily-note-cleanup — tidy a day's daily note
The subject is one daily note: **yesterday's by default**, or a date the user names. Every step below reads and edits that note, plus the **next day's** note as the one place carried-forward work lands. Nothing here touches the vault's other notes.
The notes live at `<vault>/daily/<YYYY-MM-DD>.md`. The template is `<vault>/templates/daily.md`; its only placeholder is `{{date}}`.
Do the steps in order. Each edits the note in place — touch only what a step names and leave prose, ordering, and embeds exactly as they are. The vault syncs live from other devices, so a wholesale rewrite is a sync conflict waiting to happen: make targeted edits.
## 1. Resolve the two dates
```
date -d "yesterday" +%F # subject day, when the user names none
date -d "<subject> +1 day" +%F # the next day
```
## 2. Create either note if it is missing
For a note that does not exist, read `<vault>/templates/daily.md`, replace `{{date}}` with that note's own date, and write it to `<vault>/daily/<date>.md`. A note that already exists is left untouched here — it may already hold a day's writing.
## 3. Carry pending tasks forward
Under the subject note's `## Work`, find the `Pending tasks:` line and the bullet list right under it. The block runs from that label to the next header or the next non-bullet, non-blank line. Move the whole block — label and bullets — into the **next day's** note under its `## Work`:
- Fresh next-day note: drop the block straight under `## Work`.
- Next-day `## Work` already carries a `Pending tasks:` list: append the bullets to it, don't add a second label.
Then delete the block from the subject note. If that empties `## Work`, step 6 removes the header.
No `Pending tasks:` under `## Work` means nothing to carry — skip.
## 4. Fill in the weather
If the subject note's frontmatter has no weather keys (`temp_high` and the rest), invoke the **weather** skill for the subject date; it owns reading the station and writing those keys. Already present — the scheduled run often fills yesterday — and you skip it. If weather refuses the day (a station gap, partial coverage, or a date that hasn't ended), leave the keys absent and move on.
## 5. Remove empty frontmatter fields
Delete any frontmatter line whose value is blank — `work_mood:` with nothing after the colon. Keep every field that has a value. If no frontmatter fields remain, remove the `---` fences.
## 6. Remove empty headers
Work from the deepest headers outward. Remove a header when nothing lives under it — everything from it to the next same-or-higher header is blank — and no sub-header of it survives. Removing an empty leaf can empty its parent, so re-check the parent.
Two kinds of header stay even when they look bare:
- **Embeds are content.** A header whose body is only `![[...]]` embed links stays (`## Articles`, `## Tasks`).
- **Structure is content.** A header with a surviving sub-header stays, even when its own lines are blank (`## Notes` over a `### ...` that has bullets).
Never remove the `# <date>` title. Collapse the blank lines a removed section leaves behind to a single blank between the headers that remain.
## What it looks like
A subject note the morning after, with empty template sections, blank moods, no weather yet, and a pending list under Work:
```markdown
---
work_mood:
general_mood:
weight:
---
# 2026-08-27
## Articles
![[Articles.base#Added Today]]
## Timeline
## Journal
## Notes
## Work
Pending tasks:
- Deploy the account server again.
- Refactor ReviewRequestTypes.
## Tasks
![[Daily Note Tasks.base#Completed Today]]
```
After — moods removed, weather filled (step 4 succeeded here), the pending block carried into 2026-08-28, and Timeline/Journal/Notes/Work gone (Work emptied by the carry) while Articles and Tasks stay on their embeds:
```markdown
---
temp_high: 78.1
temp_low: 55.6
gdd_base50: 14.4
solar_energy_kwh_m2: 7.02
---
# 2026-08-27
## Articles
![[Articles.base#Added Today]]
## Tasks
![[Daily Note Tasks.base#Completed Today]]
```
The next day's note gains, under its `## Work`:
```markdown
## Work
Pending tasks:
- Deploy the account server again.
- Refactor ReviewRequestTypes.
```
+40 -3
View File
@@ -2,7 +2,7 @@
`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. `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. 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 ## The goal
@@ -64,9 +64,10 @@ The full transition table is in `SKILL.md`; it exists because its absence was th
Invisible from `SKILL.md`, and the model breaks quietly without them. Invisible from `SKILL.md`, and the model breaks quietly without them.
- **Status `Wont Do`**`isCompleted: true` so it drops off the frontier, `excludeFromCycle: true` so `tn toggle` can't land on it, `autoArchive: false`. - **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. - **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. - **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. - **`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 ## Confirmed against a running vault
@@ -86,3 +87,39 @@ Probing `POST /api/tasks` the same day settled how creation works. The endpoint
- 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. - 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. - 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. - `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.
+129 -125
View File
@@ -1,21 +1,31 @@
--- ---
name: dev-tickets name: dev-tickets
description: Storage model for development tickets, specs, and planning maps, which live in the Obsidian vault as TaskNotes tasks. 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. 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 # Dev tickets
Tickets, specs, and planning maps live in the Obsidian vault at `/home/tgrosinger/Documents/Atrium`, as TaskNotes tasks. This skill owns the storage model the shape on disk, the metadata, and the commands 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. 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.
General `tn` usage — filter syntax, JSON shapes, gotchas — is the `tasknotes-cli` skill. This skill covers only what is specific to dev tickets. **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 ## 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). - **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. - **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. - **Decision ticket** — a subtask that answers a question. `/wayfinder`'s unit.
- **Implementation ticket** — a subtask that builds something. `/to-tickets`' unit. - **Implementation ticket** — a subtask that builds something. `/to-tickets`' unit.
- **Frontier** — the tickets that are open and not blocked. - **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 ## Shape
@@ -28,56 +38,57 @@ TaskNotes/Dev/atribot/
└── Split obsync into its own container.md ← implementation 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. 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`. **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]]` attaches every ticket in that repo to a personal `Obsidian.md` sitting anywhere in the vault, which then lists them in its Subtasks view. Nothing looks broken — the frontmatter still reads `[[obsidian]]` and every `tn` query still returns the right tasks — so this is found by looking, not by failing. Check a title before adopting it, with `-iname`, not `-name`: 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).
```sh
find /home/tgrosinger/Documents/Atrium -iname '<title>.md' -not -path '*/.obsidian/*'
```
## Frontmatter ## Frontmatter
`tn create` writes every one of these in a single call. The middle column names the flag. `ticket create` writes every one of these in a single call. The middle column names what sets it.
| Field | Set with | Values | | Field | Set with | Values |
|---|---|---| |---|---|---|
| `status` | `--status` | `Open` · `In Progress` (claimed) · `Done` (complete, resolved) · `Wont Do` (abandoned, out of scope) | | `status` | `--status` at creation, then `claim` / `close` | `Open` · `In Progress` (claimed) · `Done` (complete, resolved) · `Wont Do` (abandoned, out of scope) |
| `contexts` | `--contexts` | from the repo's `CLAUDE.local.md` | | `contexts` | `--context` on an effort; tickets inherit the effort's | from the repo's `CLAUDE.local.md` |
| `projects` | `--projects` | effort → `[[repo]]` · ticket → `[[effort]]` | | `projects` | derived: `--repo R``[[R]]`, `--effort E``[[E]]` | effort → `[[repo]]` · ticket → `[[effort]]` |
| actor tag | `--tags` | `agent-step` · `human-step` — exactly one, durable, survives completion | | actor tag | `--actor agent\|human` at creation; `close --actor` to correct it | `agent-step` · `human-step` — exactly one, durable, survives completion |
| `needs-info` | `tn update --add-tags` | tag, present only while stalled on an unanswered question | | `needs-info` | `stall` / `unstall` | tag, present only while stalled on an unanswered question |
| `blockedBy` | `--blocked-by` | list of `{uid, reltype}`; `reltype` is `FINISHTOSTART`. The flag is not repeatable — pass it once with every blocker in one comma-separated list | | `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` | `--prop` | `research` · `prototype` · `grilling` · `setup` · `implementation` | | `ticket_type` | `--type` | `research` · `prototype` · `grilling` · `setup` · `implementation` |
| `git_branch` | `--prop` | effort tasks only; omit entirely when on main/master | | `git_branch` | `set git_branch=…` on the effort | effort tasks only; omit entirely when on main/master |
| `git_worktree` | `--prop` | effort tasks only; omit entirely when the main worktree | | `git_worktree` | `set git_worktree=…` on the effort | effort tasks only; omit entirely when the main worktree |
| `agent_session` | `obsidian property:set` | `$CLAUDE_CODE_SESSION_ID`, overwritten each session that works the ticket | | `agent_session` | `claim` | `$CLAUDE_CODE_SESSION_ID`, overwritten each session that works the ticket |
The `task` tag is added automatically; `--tags` carries only the actor tag. 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: A ticket on disk reads:
```yaml ```yaml
--- ---
tags:
- task
- agent-step
status: Open status: Open
priority: 2-Normal
contexts: contexts:
- Obsidian - Obsidian
projects: projects:
- "[[Network isolation]]" - "[[Network isolation]]"
ticket_type: implementation date_created: 2026-08-10T09:12:44.118-07:00
date_modified: 2026-08-10T09:12:44.118-07:00
blockedBy: blockedBy:
- uid: "[[Verify internal network publish]]" - uid: "[[Verify internal network publish]]"
reltype: FINISHTOSTART reltype: FINISHTOSTART
tags:
- task
- agent-step
ticket_type: implementation
agent_session: 444e4dcb-de76-4f6b-8c39-7d08d6aa9f5f agent_session: 444e4dcb-de76-4f6b-8c39-7d08d6aa9f5f
--- ---
``` ```
`blocked` is not a status. TaskNotes derives `isBlocked` from `blockedBy` and the live status of each blocker, so blocking is recorded once, in `blockedBy`, and read back from `isBlocked`. 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. `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.
@@ -87,7 +98,7 @@ Effort task — the map first, and the spec replacing `## Destination` once the
Ticket — `## Question` for a decision ticket, `## What to build` plus acceptance checkboxes for an implementation ticket. A resolved decision ticket gains `## Answer`. 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: Every effort and ticket task takes the same two trailing sections, which `ticket create` appends when the body lacks them:
```markdown ```markdown
## Notes ## Notes
@@ -99,9 +110,9 @@ Squid rejects CONNECT to the registry on first run; the allowlist needs the CDN
## As Built ## As Built
``` ```
`## Notes` is a running log — hurdles, surprises, design changes — appended **while the work happens**, under a sub-heading wikilinking the current date. `## 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, and only when the implementation materially deviated from what the ticket asked for. `## 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 ## Repo configuration
@@ -118,44 +129,38 @@ When a repo has no such block, ask the user for the context and offer to write t
## Creating ## Creating
`tn create` writes frontmatter, custom fields, and body in one call. `--folder` is the effort directory; the note is filed there after creation. 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 ```sh
tn create --title 'Split obsync into its own container' \ ticket create --repo atribot --title 'Network isolation' --context Obsidian \
--folder 'TaskNotes/Dev/atribot/Network isolation' \ --set git_branch=feat/network-isolation \
--contexts Obsidian \ --body-file - <<'BODY'
--projects '[[Network isolation]]' \ ## Destination
--tags agent-step \
--prop ticket_type=implementation \ ...
--blocked-by '[[Verify internal network publish]],[[Provision the registry mirror]]' \ BODY
--details-file - <<'EOF' ```
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 ## What to build
- [ ] Move the compose service into its own namespace - [ ] Move the compose service into its own namespace
BODY
## Notes
## As Built
EOF
``` ```
An effort task is the same call with `--projects '[[atribot]]'`, no actor tag, and `--prop git_branch=…` when the work is not on main. `--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.
Efforts and tickets are worked from the frontier, not a calendar, so none carries a scheduled date. The plugin stamps `date_scheduled: <today>` on every task it creates, and `tn` cannot suppress or clear it — empty `--scheduled` values are dropped by both `create` and `update`. Strip it right after each create: 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.
```sh
obsidian property:remove name=date_scheduled path='TaskNotes/Dev/atribot/Network isolation/Split obsync into its own container.md'
```
Create in dependency order so a blocker's title exists before the ticket naming it. Flag reference: `tasknotes-cli`.
A second `--blocked-by` does not add a second blocker — the flag is read once, so every blocker goes in one comma-separated value as above. Commas inside `[[...]]` are literal, so titles containing commas are safe.
Run the case-insensitive, vault-wide title check above before creating every effort or ticket. If `tn create --folder` nevertheless reports that the destination already exists and leaves the new task in `TaskNotes/Tasks`, treat the creation as failed: report both paths and do not move, rename, delete, or continue using the stranded task. Resolving the collision requires the user's choice because dev-ticket titles become immutable as soon as they are created and deletion requires explicit authorization.
### Repo project notes ### 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 — `tn` matches on the frontmatter value, so a dangling link queries exactly like a resolved one. 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: 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:
@@ -165,7 +170,7 @@ Matrix bot and its companion services, deployed as compose stacks.
Repo: `/home/tgrosinger/code/atribot` Repo: `/home/tgrosinger/code/atribot`
``` ```
`tn create` cannot produce one, since it only makes tasks. Write the file directly at `TaskNotes/Dev/<repo>/<repo>.md`. `ticket` only makes tasks. Write the file directly at `TaskNotes/Dev/<repo>/<repo>.md`.
## Routing tags ## Routing tags
@@ -185,51 +190,49 @@ The actor usually follows from `ticket_type`, and `/wayfinder` calls the same di
`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. `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` | | Transition | `status` | actor tag | `needs-info` | verb |
|---|---|---|---| |---|---|---|---|---|
| Created | `Open` | set, exactly one | absent | | Created | `Open` | set, exactly one | absent | `create --actor` |
| Claimed | → `In Progress` | unchanged | unchanged | | Claimed | → `In Progress` | unchanged | unchanged | `claim` |
| Stalled on a question | unchanged | unchanged | **add** | | Stalled on a question | unchanged | unchanged | **add** | `stall` |
| Question answered | unchanged | unchanged | **remove** | | Question answered | unchanged | unchanged | **remove** | `unstall` |
| Reassigned to the other actor | unchanged | **swap** | unchanged | | Reassigned to the other actor | unchanged | **swap** | unchanged | `set tags+=… tags-=…` |
| Completed | → `Done` | **swap if the other actor did the work** | must be absent | | Completed | → `Done` | **swap if the other actor did the work** | must be absent | `close [--actor]` |
| Abandoned | → `Wont Do` | unchanged | remove if present | | Abandoned | → `Wont Do` | unchanged | remove if present | `close --wont-do` |
Two rules are easy to forget, and both corrupt a query when missed: 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.
- **Correct the actor tag at close.** A ticket scoped `agent-step` that the human finished by hand closes as `human-step`. This is what keeps `agent-step AND status:Done` a truthful record of what agents actually shipped, rather than what was merely hoped for.
- **Clear `needs-info` before closing.** A closed ticket carrying it pollutes the human's inbox permanently.
Tag writes go through `tn`:
```sh
tn update '<path>' --add-tags needs-info
tn update '<path>' --remove-tags needs-info
tn update '<path>' --add-tags human-step --remove-tags agent-step
```
## Working a ticket ## Working a ticket
1. **Claim it** before any work, so a concurrent session skips it: 1. **Claim it** before any work, so a concurrent session skips it:
```sh ```sh
tn update 'TaskNotes/Dev/atribot/Network isolation/Split obsync into its own container.md' --status 'In Progress' ticket claim 'Split obsync into its own container'
obsidian property:set name=agent_session value="$CLAUDE_CODE_SESSION_ID" path='TaskNotes/Dev/atribot/Network isolation/Split obsync into its own container.md'
``` ```
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`: 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 ```sh
obsidian property:set name=git_branch value='feat/network-isolation' path='TaskNotes/Dev/atribot/Network isolation/Network isolation.md' ticket set 'Network isolation' git_branch=feat/network-isolation git_worktree=/home/tgrosinger/code/atribot-ni
obsidian property:set name=git_worktree value='/home/tgrosinger/code/atribot-ni' path='TaskNotes/Dev/atribot/Network isolation/Network isolation.md'
``` ```
Omit `git_branch` on `main`/`master`, and omit `git_worktree` in the main worktree. If the checkout changes later, update both fields to describe the checkout currently used for the effort. 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 under a `### [[YYYY-MM-DD]]` sub-heading. `obsidian append` writes at end of file, which files the entry under `## As Built`; insert it above that heading instead: 3. **Append notes as you go**; each lands under today's `### [[YYYY-MM-DD]]` heading (reused within a day), above `## As Built`:
```sh ```sh
obsidian eval code='const note = "\n### [[2026-08-10]]\n\nSquid rejects CONNECT to the registry on first run.\n"; ticket note 'Split obsync into its own container' 'Squid rejects CONNECT to the registry on first run.'
const f = app.vault.getAbstractFileByPath("<ticket path>"); ticket note 'Split obsync into its own container' --file - # longer text on stdin
app.vault.process(f, d => d.replace("\n## As Built", note + "\n## As Built"));'
``` ```
For a note long enough to fight the shell quoting, put the JS in a file and pass `code="$(cat note.js)"` — command-substitution output is not re-expanded, so backticks and `$` in the JS are safe. Wrap any `await` in an async IIFE; `eval` runs the code as a plain script. 4. **Stall it** if you hit a question only the user can answer — the tag and the logged question are one write:
4. **Stall it** if you hit a question only the user can answer: `tn update '<path>' --add-tags needs-info`, and log the question under `## Notes`. Leave `status` at `In Progress` — the ticket is still yours, it just cannot proceed. Remove the tag once answered. ```sh
5. **Close it**`tn update '<path>' --status Done`, or `--status 'Wont Do'` when abandoned. Before closing: add `## As Built` if the implementation deviated, remove `needs-info` if present, and swap the actor tag if the other actor did the work. 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. Closing a ticket unblocks its dependents automatically; there is nothing to update on them.
@@ -237,64 +240,65 @@ To resume a ticket: `cd` to its effort's `git_worktree`, then `claude --resume <
## Querying ## Querying
The frontier of one effort: 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 ```sh
tn list --filter 'projects:contains:"[[Network isolation]]"' --json \ ticket frontier 'Network isolation'
| jq '[.data.tasks[] | select((.isBlocked | not) and .status == "Open" and (.archived | not))]' ticket frontier 'Network isolation' --actor agent --json
``` ```
The **agent frontier**what an agent may pick up right now. Open, unblocked, agent work, and not stalled: **Awaiting a human** — both axes reach the human, so the inbox is their union, minus completed work, across the whole vault:
```sh ```sh
tn list --filter 'projects:contains:"[[Network isolation]]" AND tags:contains:agent-step AND status:is:Open AND archived:false AND dependencies.isBlocked:false AND tags:does-not-contain:needs-info' --json ticket inbox
``` ```
Every effort in a repo: `tn list --filter 'projects:contains:"[[atribot]]"' --json` Everything else is `list`, scoped to `TaskNotes/Dev/` unless `--all`; the filters AND together:
**Awaiting a human** — both axes reach the human, so the inbox is their union:
```sh ```sh
tn list --filter '(tags:contains:human-step OR tags:contains:needs-info) AND status:is-not:Done' --json 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")'
``` ```
The `status` filter is not optional there. Actor tags are durable, so without it the query returns every human step ever 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.
`--filter` returns completed and archived tasks too, so filter on status when you want live work. Text output is one row per note — status (with `blocked` / `needs-info` flags), actor, title, path. `--json` prints records; pipe to `jq`:
The dependency fields are spelled differently in the JSON than in a filter expression, which is the easy thing to get wrong here:
- **In the JSON**, every task carries top-level `isBlocked` and `isBlocking` booleans — that is why the frontier query above reads `.isBlocked`, not `.dependencies.isBlocked`. There is no `dependencies` object in the response. The `blockedBy` and `blocking` arrays appear only on tasks that actually have them.
- **In a `--filter` expression**, those same booleans are `dependencies.isBlocked` and `dependencies.isBlocking`. Bare `isBlocked` is not a filter property; it fails outright with `Filter parsing error: Unknown property: isBlocked`.
So the frontier can be narrowed server-side instead of in jq:
```sh ```sh
tn list --filter 'projects:contains:"[[Network isolation]]" AND dependencies.isBlocked:false AND status:is:Open AND archived:false' --json ticket frontier 'Network isolation' --actor agent --json | jq -r '.[0].path'
``` ```
## Tools ## Cheatsheet
`tn create` covers creation entirely, custom properties and body included. The split only matters afterwards: ```
ticket vault [--set PATH] print the vault path, or record it for this host
- **`tn update`** for everything TaskNotes models — status, priority, dates, estimate, tags, contexts, projects, `blockedBy` — and all queries. It goes through the update service, which maintains `date_modified` and recurring-instance bookkeeping. ticket show REF print a note verbatim (--json: its record with body)
- **`obsidian`** for what `tn update` has no flag for: custom properties after creation (`agent_session`, `git_branch`, `git_worktree`) and body text. `obsidian property:set` writes YAML directly and skips the update service. 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
In a sandboxed session, invoke `obsidian` only as a simple or `&&`-chained command. The command is the Obsidian AppImage, and the sandbox denies its FUSE mount when the call sits inside a shell loop — every iteration fails with `fuse: device not found`. Write a batch over many notes as chained single calls, not a loop. 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|-]
Mid-flight dependency edits are rare, since blockers are written at creation. When one is needed: 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
```sh ticket note REF TEXT|--file F [--date YYYY-MM-DD] append to ## Notes, above ## As Built
tn update '<ticket path>' --add-blocked-by '[[Verify internal network publish]]' ticket as-built REF TEXT|--file F append under ## As Built
tn update '<ticket path>' --remove-blocked-by 'Verify internal network publish' 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
``` ```
Targets compare by resolved note path, so the bracketed and bare forms name the same ticket, and removing the last blocker deletes the `blockedBy` key. 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 ## Guardrails
- **Create a new ticket rather than renaming a published one.** Every inbound `projects` and `blockedBy` edge points at the title. - **Create a new ticket rather than renaming a published one.** Every inbound `projects` and `blockedBy` edge points at the title.
- **Change an indexed note through `tn` or `obsidian`,** never by editing the file after it is created. Obsidian holds the live index and rewrites the file underneath a direct edit. - **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.
- `obsidian task` and `obsidian tasks` operate on markdown checkboxes and have nothing to do with TaskNotes. - **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). Why the model is shaped this way, and what was rejected on the way here: [README.md](./README.md).
+128
View File
@@ -0,0 +1,128 @@
---
name: show-me
description: Help the user understand the current topic visually with concise diagrams, code-shape sketches, and focused HTML artifacts.
source: https://github.com/humanlayer/skills/blob/main/plugins/show-me/skills/show-me/SKILL.md
---
Help the user understand the current topic of conversation visually. Skip the preamble and keep prose brief. Pick the smallest view that makes the key point clear.
- Show logic or an algorithm as pseudocode:
```text
on(save)
if content is unchanged
return cached result
write new content
return fresh result
```
- Show runtime control flow as a call tree:
```text
submitForm
createSession
persistPrompt
launchAgent
navigateToSession
```
- Show UI structure as a component tree, including state and module boundaries that matter:
```tsx
<SessionPage> (apps/example/src/routes/session.tsx)
useSessionEvents()
<SessionToolbar>
<RunSkillButton> (packages/ui)
```
- Show file responsibility or a broad refactor as a shallow file tree:
```text
src/
├── commands/ # parses user actions
├── sessions/ # owns session state
└── transport/ # sends API requests
```
- Show component interaction, control flow, or data flow with Mermaid:
```mermaid
sequenceDiagram
participant User
participant UI
participant Daemon
User->>UI: choose command
UI->>Daemon: send expanded prompt
Daemon-->>UI: stream result
```
- Use `diff` when the point is what changes and the surrounding shape already exists. Match the diff shape to the topic.
For a component change:
```diff
<SessionPage>
useSessionEvents()
<SessionToolbar>
+ <RunSkillButton />
<SessionTimeline>
+ <SkillResultCard />
```
For a file-layout change:
```diff
src/
├── commands/
+│ └── show-me.ts # expands the slash command
├── sessions/
-└── transport.ts
+└── transport/
+ ├── client.ts
+ └── stream.ts
```
For a call-tree or call-stack change:
```diff
submitForm
createSession
persistPrompt
+ expandSkillMention
launchAgent
- navigateToSession
+ navigateToSession
+ subscribeToEvents
```
For a state or control-flow change:
```diff
on(save)
- write content
+ if content is unchanged
+ return cached result
+ write new content
+ invalidate cache
```
- Show the whole block when most of it is new, when omitted context would hide ownership or order, or when the user needs a copyable target shape:
```ts
function expandSkill(command: string): string {
const skillName = command.slice(1)
return `use the ${skillName} skill`
}
```
- For a visual UI, layout, state comparison, or concept too dense for Mermaid, write one focused HTML file — a diagram, an infographic, or a short slide deck, whichever fits the point. Match the product's colors, type, spacing, and components; use real labels and data; support desktop and mobile. Then open it for the user:
```
Bash(open path/to/show-me-{description}.html)
```
### guidance
Place each visual next to the short text it supports. Keep only the calls, files, props, states, and boundaries needed to answer the user's current question or the options to resolve the current discussion point.
You may use one of these, you may use several, it is unlikely you will use all of them. Use your judgement and don't overwhelm the user.
-229
View File
@@ -1,229 +0,0 @@
---
name: tasknotes-cli
description: Query, create, and update TaskNotes tasks and projects from the command line with `tn`, the TaskNotes CLI.
---
# TaskNotes CLI (`tn`)
`tn` is a thin **client**. It does not read the vault's markdown — it talks to an HTTP API served by the TaskNotes plugin running **inside Obsidian** (`localhost:9898`, bearer token). Obsidian must be open. In sandboxed environments, (such as Codex) `localhost` may refer to the sandbox and cannot reach the host. In this case, request permission to run the command from outside the sandbox.
Handle `Cannot connect to TaskNotes API` according to where it occurred:
- In Claude Code's Bash sandbox, a `tn` call inside `$(command substitution)` or a `for` loop can fail even while a plain pipeline works. Re-run the same query once as a bare `tn ... | jq ...` pipeline.
- If the bare command is blocked because sandboxed `localhost` cannot reach the host, request permission to run it outside the sandbox.
- If a bare command outside that limitation still cannot connect, no server is listening: Obsidian is not running or the API is disabled in plugin settings. Report that result; do not retry command variants or attempt to start or reconfigure Obsidian.
## Vocabulary comes from config, not from this file
Statuses and priorities are user-defined and matched **exactly, capitalization included**. Run `tn filter-options` to see the live set along with the tags, contexts, and projects actually in use. You can provide `--json` for a structured output.
Current vault values:
- Statuses: `Open`, `In Progress`, `Done`, `Wont Do`
- Priorities: `0-None`, `1-Low`, `2-Normal`, `3-High`, `4-Urgent`
- Contexts: @Coding, @Obsidian, @Tiltshift
## Task IDs
A task ID is its vault-relative path: `TaskNotes/Tasks/Check for eggs.md`. Read it from the `ID:` line in text output, or:
```sh
tn list --json | jq -r '.data.tasks[].path'
```
Always quote an ID — the paths contain spaces.
## Querying
```sh
tn list # incomplete tasks only, 20 max
tn list --today # scheduled today
tn list --overdue # due before now and not complete
tn list --completed
tn list --limit 50 --json
```
`--filter` takes an expression and **cannot be combined with `--today`, `--overdue`, or `--completed`** — the CLI errors out.
```sh
tn list --filter 'priority:4-Urgent AND tags:home'
tn list --filter '(priority:4-Urgent OR priority:3-High) AND archived:false'
tn list --filter 'due:before:2026-09-01 AND status:is-not:Done'
tn list --filter 'title:contains:"west wall"'
tn list --filter 'dependencies.isBlocked:false AND status:is-not:Done'
tn list --filter 'blockedBy:contains:"[[Pour the footings]]"'
```
Two things about `--filter` that will bite otherwise:
- It applies **no default exclusions**. Plain `tn list` hides completed tasks; `--filter` does not. Add `status:is-not:Done` and `archived:false` when you mean active tasks.
- `--limit` is applied client-side *after* every match is fetched, so it caps display, not work.
`--json` returns `{ success, data: { tasks, total, filtered, count, vault }, meta: {...} }` — tasks live under `.data.tasks`. Progress lines (`- Fetching tasks...`, `✔ Found 111 tasks`) go to **stderr**, so `tn ... --json | jq` works without redirecting; `2>&1 | jq` is what breaks it.
### Dependency fields: JSON vs. filter
The blocked/blocking booleans are named one way in the response and another way in a filter expression. Both spellings are correct in their own place:
| | JSON (`jq`) | `--filter` expression |
|---|---|---|
| blocked | `.isBlocked` | `dependencies.isBlocked` |
| blocking | `.isBlocking` | `dependencies.isBlocking` |
There is **no `dependencies` object in the JSON**`.dependencies.isBlocked` is always null. Conversely, bare `isBlocked` is not a filter property and errors with `Unknown property: isBlocked`.
`isBlocked` and `isBlocking` are on every task. The related arrays are present only when non-empty: `blocking` is a list of task paths, and `blockedBy` is a list of objects, not strings:
```json
"blockedBy": [ { "uid": "Pour the footings", "reltype": "FINISHTOSTART" } ],
"blocking": [ "TaskNotes/Tasks/Paint the trim.md" ],
"isBlocked": true,
"isBlocking": false
```
`blockedBy` and `blocking` are also filter properties in their own right (`blocking:not-empty`, `blockedBy:contains:"[[Pour the footings]]"`).
### Filter syntax
`property:value` (operator inferred) or `property:operator:value`, combined with `AND` / `OR` and parentheses.
Properties: `title`, `path`, `status`, `priority`, `tags`, `contexts`, `projects`, `blockedBy`, `blocking`, `due`, `scheduled`, `completedDate`, `dateCreated`, `dateModified`, `archived`, `hasSubtasks`, `dependencies.isBlocked`, `dependencies.isBlocking`, `timeEstimate`, `recurrence`, `status.isCompleted`. These aliases are rewritten for you: `tag`, `context`, `project`, `created` (→ `dateCreated`), `modified` (→ `dateModified`), `completed` (→ `completedDate`), `estimate` (→ `timeEstimate`).
Operators: `is`, `is-not`, `contains`, `does-not-contain`, `is-before`, `is-after`, `is-on-or-before`, `is-on-or-after`, `is-empty`, `is-not-empty`, `is-greater-than`, `is-less-than`, `is-greater-than-or-equal`, `is-less-than-or-equal`, `is-checked`, `is-not-checked`. The short forms `before`, `after`, `on-or-before`, `on-or-after`, `greater-than`, `less-than`, `greater-than-or-equal`, `less-than-or-equal`, `empty`, `not-empty`, `checked`, `not-checked` all map onto those.
Gotchas:
- Unquoted values may only contain letters, digits, `_`, `.`, `-`, and `/`. Anything with a space, `[`, or `]` must be double-quoted *inside* the expression: `--filter 'projects:contains:"Barn painting"'`.
- Booleans (`archived`, `status.isCompleted`, `hasSubtasks`, `dependencies.isBlocked`, `dependencies.isBlocking`): `archived:true` matches archived; any other value matches not-archived.
- A bare `property:empty`, `not-empty`, `checked`, or `not-checked` parses as that operator with no value — `blockedBy:not-empty` is complete as written.
- A misspelled **property** is caught by the CLI whitelist and fails loudly — `Filter parsing error: Unknown property: foo`, non-zero exit, no JSON. A bogus **value** does not: `status:is:Bogus` returns `success: true` with zero tasks, so a typo there looks like "no matches".
### Search and stats
`tn search <query>` is a client-side substring match over title, details, tags, contexts, projects, and path. It only sees non-archived, incomplete tasks. `--json` returns the same envelope as `tn list`, and `--limit` (default 20) caps what is shown; the message below the results counts the matches it left out.
`tn stats` gives counts and insights. `tn filter-options` lists the available filter values.
## Creating
Two forms. Natural language for quick capture; `--title` and flags when the task needs a body, custom frontmatter, or a specific folder. Give one or the other — text plus `--title` is not a combination.
### From natural language
```sh
tn 'Replace planer belt tomorrow @shop +Shop maintenance'
tn create 'Call the vet about the goats due friday high priority'
```
The text is parsed as natural language server-side. Triggers enabled in this vault: `#tag`, `@context`, `+project`, `*status`.
- **`!priority` is disabled here.** Write "high priority" in the text, or set it afterwards with `tn update --priority 3-High`.
- Bare dates become **scheduled**, not due (`nlpDefaultToScheduled` is on). Say "due friday" explicitly for a due date.
- Also parsed: `tomorrow`, `friday`, `next week`, `2026-12-25`; estimates `2h`, `30min`; recurrence `daily`, `every monday`.
- **Single-quote the whole task text.** Unquoted `#` starts a shell comment and `!` triggers history expansion.
The command prints the parsed fields — check them rather than assuming the phrasing landed.
### From flags
```sh
tn create --title 'Split obsync into its own container' \
--folder 'TaskNotes/Dev/atribot/Network isolation' \
--scheduled 2026-08-12 \
--contexts Obsidian \
--projects '[[Network isolation]]' \
--tags agent-step \
--blocked-by '[[Verify internal network publish]]' \
--prop ticket_type=implementation \
--details-file - <<'EOF'
## What to build
- [ ] Move the compose service into its own namespace
EOF
```
| Flag | Notes |
|---|---|
| `--details <text>` / `--details-file <path>` | Note body. `--details-file -` reads stdin. |
| `--prop <key=value>` | Custom frontmatter property. Repeatable. |
| `--contexts` `--projects` `--tags` `--blocked-by` | Comma-separated, splitting the same way as `tn update` — commas inside `[[...]]` are literal. |
| `--status` `--priority` `--due` `--scheduled` `--estimate` | Same values as `tn update`. |
| `--folder <dir>` | Vault-relative destination. See below. |
| `--json` | Emit the created task, including its final path. |
- The `task` tag is added automatically; `--tags` merges with it, so pass only the tags beyond it.
- `--blocked-by` writes the full `{uid, reltype: FINISHTOSTART}` shape.
- Date keys are mapped for you: `--scheduled` lands as `date_scheduled` in the frontmatter.
- Omitting `--scheduled` leaves the plugin's default, which is **today**, not empty.
#### `--folder`
The plugin ignores any folder in the create payload and always files new notes into its configured tasks folder (`TaskNotes/Tasks`). `--folder` works around that server-side gap: `tn` creates the task, then moves the file and reports the final path.
- The folder is validated **before** the task is created — absolute paths and `..` are rejected — so a bad `--folder` cannot strand a note.
- Missing directories are created.
- When a note already exists at the destination, `tn` **aborts without overwriting** and reports that the new task is sitting in the tasks folder instead. Both notes survive. Treat creation as incomplete and report both paths; do not move, rename, or delete either note unless the caller's storage model explicitly defines a recovery. In particular, dev-ticket titles are immutable once created, so follow the dev-ticket collision procedure rather than renaming the stranded task.
- `--folder` applies to the natural-language form too.
## Updating
```sh
tn update 'TaskNotes/Tasks/Water nut trees.md' --status 'In Progress'
tn update <id> --priority 3-High --due 2026-08-20 --estimate 90
tn update <id> --title 'Water the nut trees' --scheduled 2026-08-21
```
`--status`, `--priority`, `--due`, `--scheduled`, `--title`, and `--estimate` travel in one `PUT`. `--estimate` is in minutes, and at least one flag is required.
The list flags each go to their own endpoint, after that `PUT` and one request per field:
```sh
tn update <id> --add-tags 'urgent,bug' --remove-tags 'later'
tn update <id> --add-contexts Coding --add-projects '[[Barn painting]]'
tn update <id> --add-blocked-by '[[Pour the footings]]' --remove-blocked-by '[[Old blocker]]'
```
- Values split on commas **except inside `[[...]]`**, so a wikilink may contain a comma.
- Projects and dependencies compare by resolved note path: `[[Barn painting]]` and `Barn painting` are one target, and removing the last entry deletes the frontmatter key.
- Adds are a set union — re-adding an existing value is a no-op, not a duplicate.
- Removing the `task` identification tag returns 400 rather than de-tasking the note. An unresolvable `--add-blocked-by` target returns 400 too.
- These endpoints arrived after plugin 4.12.3. Against an older plugin `tn` reports *"This version of the TaskNotes plugin has no list-modification endpoints"* — that means update the plugin, not that the task is missing.
State changes:
- `tn complete <id>` — marks complete; warns instead of acting if it already is.
- `tn toggle <id>` — cycles status. `In Progress` is excluded from the cycle here, so this goes `Open``Done`. Reach `In Progress` with `tn update --status 'In Progress'`.
- `tn archive <id>` — toggles the archive flag. Archived tasks **move to `TaskNotes/Archive/`, so the task ID changes**. Re-query before acting on it again.
- `tn recurring-complete <id> <YYYY-MM-DD>` — completes one occurrence of a recurring task. Use this, not `tn complete`, for anything with a `recurrence`.
- `tn delete <id> --force``--force` is mandatory (there is no interactive prompt).
## Projects
Projects are **not first-class objects**. They exist only as `projects:` frontmatter wikilinks on tasks (`- "[[Barn painting]]"`), and a project "exists" as long as some task references it.
**`tn projects create` always fails** — it is hard-coded to error out. Create a project by assigning it:
```sh
tn 'Paint the trim +[[Barn painting]]'
tn update '<task path>' --add-projects '[[Barn painting]]'
```
Use `+[[Wikilink]]` for any multi-word project — bare `+Barn painting` parses as project `Barn` and leaves `painting` in the title.
`--period` is accepted by the parser but does nothing.
```sh
tn projects list # derived from up to 1000 active tasks
tn projects show 'Barn painting'
tn projects stats 'Barn painting'
```
All three compare names with `[[ ]]` stripped, so a name copied out of `list` matches whether or not you bracket it. Both `show` and `stats` skip archived tasks, and under `--json` `stats` returns a zeroed stats object rather than prose when nothing matches.
## Never do
- **Never run bare `tn` or `tn interactive`** — they launch a full-screen TUI and hang a non-interactive shell. Always pass a subcommand or quoted task text.
- Never `tn delete` without an explicit request.
- Don't retry variants on a connection error; Obsidian isn't running.
- Don't hand-edit `TaskNotes/*.md` frontmatter to do something the CLI can do — Obsidian holds the live index and will overwrite you.
- Time tracking (`tn timer`) and Pomodoro (`tn pomodoro`) are out of scope for this skill.
+105
View File
@@ -0,0 +1,105 @@
---
name: weather
description: Record a day's high temperature, low temperature, rainfall, growing degree days, solar energy, and freezing exposure in that day's daily note, read from the home weather station. Use whenever the user asks to log, add, backfill, or look up the weather for a date — including when they say only "yesterday's weather" or "add the weather to my daily note", when they ask about growing degree days, GDD, accumulated heat, solar energy, frost or freezing hours, and including the scheduled run that fills in the day that just ended.
---
# weather — a day's weather in the daily note
Shell the `weather-day` CLI from in this skill's `scripts` directory with the bash tool, then write its values into the daily note's frontmatter. Nothing here needs a key or a login — the station is public.
## Getting the numbers
```
weather-day [YYYY-MM-DD] # no argument: yesterday
```
One JSON object on stdout:
```json
{ "date": "2026-08-18", "station_id": 173994,
"temp_high": 72.3, "temp_low": 54.1, "rainfall": 0.02,
"gdd_base50": 11.4, "solar_energy_kwh_m2": 6.83,
"observations": 287, "coverage_hours": 23.9 }
```
On a day that went below freezing, two more keys appear between `solar_energy_kwh_m2` and `observations`:
```json
"hours_below_freezing": 7.2, "freezing_degree_hours": 31.8,
```
| key | units | what it is |
|---|---|---|
| `temp_high`, `temp_low` | °F | the day's extremes |
| `rainfall` | inches | total for the day |
| `gdd_base50` | °F-days | growing degree days, base 50°F |
| `solar_energy_kwh_m2` | kWh/m² | total solar energy on a horizontal surface |
| `hours_below_freezing` | hours | time spent under 32°F |
| `freezing_degree_hours` | °F-hours | how far under 32°F, integrated over that time |
**Everything from `date` through `freezing_degree_hours` is a frontmatter key** — copy the values across unchanged rather than reformatting or re-rounding them. `observations` and `coverage_hours` are diagnostics; they stay out of the note.
Three of them are written only when they have something to say. **`rainfall` is omitted from the note when it is `0`** — most days here are dry, and a `rainfall: 0` on four notes in five is noise that makes the days it did rain harder to spot. `hours_below_freezing` and `freezing_degree_hours` are omitted by `weather-day` itself on a day that never went below 32°F, so they are simply not there to copy. Either way the key's absence is the record: a dry day, a day with no frost.
Note the difference in where the omission happens. `weather-day` always prints `rainfall`, `0` included — that is a measurement, and the CLI reports it. Dropping it is this skill's decision about the note, so do not expect the JSON to be missing it.
**`gdd_base50` is one day's accumulation, not a running total.** It is the number you *sum* to answer a question — days from planting to harvest, from transplanting to first bloom, from fruit set to ripe. A single day's value on its own says very little. When the user asks "how many GDD since I planted the tomatoes", add up the `gdd_base50` of each daily note from that date forward; say how many days you summed and flag any in the range that have no value rather than treating a missing day as zero.
Any failure exits non-zero with a message on stderr. Report it and stop; do not write a partial or guessed value into the note. That includes the station going quiet mid-day: the totals are integrals over the whole day, so rather than draw a straight line across an hour or more of missing readings, `weather-day` refuses the date outright. There is no partial answer to salvage and nothing to write — say which stretch is missing and leave the note alone.
## Check the coverage before writing
`coverage_hours` is the span from the first observation to the last. A full day is ~24 (23 or 25 across a DST change).
`observations` is a count, not a duration, and a low-looking one is not by itself a problem: the API answers a day-long range on a 5-minute grid, so a complete day is around 288, not the ~1440 a per-minute feed would give. Judge the day by `coverage_hours`.
**If `coverage_hours` is under 20, do not write to the note.** Tell the user the day is only partly covered and give them the numbers to judge. A high or low computed from part of a day looks entirely normal in the frontmatter and stays wrong forever. It usually means the station was offline for a stretch, or the date asked for is today and the day hasn't finished.
A hole *inside* the day is a hard failure rather than a low `coverage_hours`, so a number that gets here has no outage longer than an hour in it. What `coverage_hours` still catches is a day the station started or stopped reporting partway through — and for the totals that is worse than for the extremes: `gdd_base50` and `solar_energy_kwh_m2` are missing the hours that never arrived, so they come out low, plausibly, and stay that way in every season sum afterwards.
## Writing it into the note
The note is `<vault>/daily/<date>.md` — the date exactly as `weather-day` echoed it back, and the absolute path, always.
**If the note exists:** add or update only the keys you are writing. Every other key keeps its value and its position, and the body is untouched — the vault syncs live from other devices, so a rewritten note is a sync conflict waiting to happen. Add new keys at the end of the frontmatter block.
**If the note does not exist:** create it with just those keys and the date as an H1.
```markdown
---
temp_high: 72.3
temp_low: 54.1
rainfall: 0.02
gdd_base50: 11.4
solar_energy_kwh_m2: 6.83
---
# 2026-08-18
```
A dry day with no frost is the common shape, and has neither `rainfall` nor the freezing pair:
```markdown
---
temp_high: 78.1
temp_low: 55.6
gdd_base50: 14.4
solar_energy_kwh_m2: 7.02
---
# 2026-08-19
```
**Any of those keys you are not writing, delete from the note if it is already there** — `rainfall` when the day was dry, `hours_below_freezing` and `freezing_degree_hours` when it never froze. Absence is the record, so a value left behind from an earlier run reports rain or a frost that did not happen, in exactly the format a real one would take. This is the one case where this skill removes rather than only adds, and it reaches only those three keys — never anything else in the frontmatter.
Re-running a date is otherwise safe: the same keys get the same values again.
## Reading it back
To answer "what was the weather on X" for a day already recorded, read the note's frontmatter — that is faster than a station call and it is the recorded value. Call `weather-day` when the note has no weather keys yet, or when the user wants the station's answer rather than the note's.
**A missing `rainfall` on a note that carries the other weather keys means zero, not unknown.** The same goes for the freezing pair. So when totalling rain over a month or frost hours over a winter, a note with `temp_high` but no `rainfall` is a recorded dry day: count it as `0` and as a day you have data for. Reading it as a gap gets the total right by luck and everything around it wrong — it reports most of a dry July as unmeasured, and sends you re-running `weather-day` over days that were never missing. A note with *none* of the weather keys is the genuinely unrecorded case, and that one is worth saying out loud or filling in before answering.
`gdd_base50` is different — it is written on every recorded day, including a `0`. A daily note missing it has not been filled in, and should be reported as a hole rather than summed as zero.
## A different station
`STATION_ID` is a constant at the top of the `weather-day` script, in this skill's directory. Changing it needs an image rebuild; say so rather than trying to override it at the command line.
+290
View File
@@ -0,0 +1,290 @@
#!/usr/bin/env bash
# High/low temperature, total rainfall, growing degree days, solar energy, and
# freezing exposure for one local day, read from a public Tempest (WeatherFlow)
# station. Prints one JSON object on stdout.
# Usage: weather-day [YYYY-MM-DD] (default: yesterday)
#
# The station is public, so the key below is WeatherFlow's own web-app key rather
# than a secret — it is what tempestwx.com sends, works for any public station, and
# is documented as the default in the weather-stats repo's .env.example. Nothing
# here belongs in .env: swap the two constants and rebuild to point at a different
# station.
#
# Reaches swd.weatherflow.com, which must be on the `acl allowed` line in
# config/squid.conf or every call here fails with a 403 from the proxy. That
# coupling cannot be asserted at build time — squid.conf is mounted into the squid
# sidecar and never exists inside this image.
set -euo pipefail
STATION_ID=173994
API_KEY=6bff2f89-84ab-463c-886e-fc0f443da4cf
API_BASE=https://swd.weatherflow.com/swd/rest
BUILD=169
# Everything below the high/low is an integral, and an integral fails quietly: a
# wrong high looks wrong, a wrong degree-day total looks like a number. So this
# one constant guards both ways a day's samples can misrepresent the day.
#
# As the *fetch pad*, it is how far either side of midnight the observations query
# reaches, so the samples bracketing each boundary exist and the day can be
# interpolated to its true edges instead of truncated to the first and last
# reading inside it.
#
# As the *gap limit*, it is the widest hole between consecutive samples the
# integrals will cross. This endpoint answers a day-long range on a 5-minute grid
# — 2026-08-18 came back as 287 observations spanning 23.8 h, which is 300.0 s
# apart — so an hour-long hole is a dozen consecutive reports missing, an outage
# rather than a few dropped ones. A straight line drawn across one, through a
# summer afternoon especially, moves the day's totals a long way while looking
# entirely ordinary in the note. Better to refuse the day.
#
# Measured rather than assumed, and worth re-measuring before leaning on it: the
# station itself reports far more often than this, so the 5 minutes is the API's
# resolution for a range this wide, not the hardware's.
#
# One number for both on purpose: a neighbouring observation further out than the
# pad is one the gap limit would refuse to interpolate across anyway, so widening
# either alone buys nothing.
MAX_GAP_SECONDS=3600
# Both headers are load-bearing, not politeness, and the key is why: it was lifted
# from the tempestwx.com JavaScript bundle, and WeatherFlow gates it on the request
# resembling that web app. Sending neither returns 401, observed — these are the
# two values the working weather-stats client sends on every call
# (src/lib/server/collectors/tempest-api.ts), and `build` is part of the same act.
# Drop any of the three and expect the 401 back.
ORIGIN=https://tempestwx.com
USER_AGENT='Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0'
if [ $# -gt 1 ]; then
echo 'usage: weather-day [YYYY-MM-DD] (default: yesterday)' >&2
exit 2
fi
day=${1:-$(date -d yesterday +%F)}
# Rejected here rather than passed through: `date -d` accepts a great deal that is
# not a calendar date ("now", "next friday", "1 day ago"), and any of them would
# produce a plausible-looking result filed under a nonsense `date` key.
if ! [[ $day =~ ^[0-9]{4}-[0-9]{2}-[0-9]{2}$ ]]; then
echo "weather-day: '$day' is not a date in YYYY-MM-DD form" >&2
exit 2
fi
if ! date -d "$day" >/dev/null 2>&1; then
echo "weather-day: '$day' is not a real calendar date" >&2
exit 2
fi
# The container's TZ decides where the day starts, which is what makes this the
# user's day rather than UTC's. `+ 1 day` is calendar arithmetic rather than
# +86400, so a DST transition gives a 23- or 25-hour day instead of one shifted by
# an hour — verified at both 2026 transitions.
#
# The bare date, with no `00:00:00`, is load-bearing. GNU date parses a `+N` that
# follows a *time* as a numeric timezone, so `"$day 00:00:00 + 1 day"` yields
# 16:00 the same day and silently loses a third of it.
start=$(date -d "$day" +%s)
end=$(date -d "$day + 1 day" +%s)
# Shared by both calls: check the HTTP status before handing the body to jq, so a
# proxy denial or an API outage reports itself instead of surfacing as "no
# observations" for a day the station was up.
tempest_get() {
local endpoint=$1 resp code json
resp=$(curl -sS -w $'\n%{http_code}' -G "${API_BASE}/${endpoint}" \
-A "${USER_AGENT}" \
-H "Origin: ${ORIGIN}" \
--data-urlencode "api_key=${API_KEY}" \
--data-urlencode "build=${BUILD}" \
"${@:2}")
code=${resp##*$'\n'}
json=${resp%$'\n'*}
if [ "$code" != "200" ]; then
echo "weather-day: ${endpoint} failed (HTTP $code)" >&2
echo " $json" >&2
exit 1
fi
printf '%s' "$json"
}
# A station has one ST (Tempest) device and usually an HB (hub) alongside it; only
# the ST reports weather.
locations=$(tempest_get "locations/${STATION_ID}" --data-urlencode 'include_arbitrary_locations=true')
device_id=$(printf '%s' "$locations" | jq -r '
if .status.status_code != 0 then
"ERR:\(.status.status_message)"
else
(.locations[0].devices[]? | select(.device_type == "ST") | .device_id) // "ERR:no ST device"
end' | head -1)
case "$device_id" in
ERR:*)
echo "weather-day: station ${STATION_ID}: ${device_id#ERR:}" >&2
exit 1
;;
''|*[!0-9]*)
echo "weather-day: station ${STATION_ID}: could not resolve a Tempest device id" >&2
exit 1
;;
esac
# Deliberately wider than the day on both sides — MAX_GAP_SECONDS of padding — so
# the observations either side of each midnight come back and the boundaries can
# be interpolated. Without them the first integral segment would start at whenever
# the station happened to report after 00:00 and the day would be quietly short.
#
# The padding is *only* for interpolation. The jq filter below re-asserts the
# half-open window itself, so temp_high, temp_low, rainfall, observations and
# coverage_hours still describe the requested day and nothing else — which also
# makes time_end's inclusivity on the API side stop mattering here.
observations=$(tempest_get "observations/device/${device_id}" \
--data-urlencode "time_start=$((start - MAX_GAP_SECONDS))" \
--data-urlencode "time_end=$((end + MAX_GAP_SECONDS))")
# Positional obs_st layout, per the OBS_IDX table in weather-stats
# (src/lib/server/collectors/tempest-api.ts): [0] epoch, [7] air temperature in C,
# [11] solar radiation in W/m², [12] precipitation in mm accumulated over the
# report interval.
#
# Rainfall is the sum of [12] rather than the station's own
# precip_accum_local_day, which resets at the station's midnight — summing keeps
# the total tied to the window actually requested.
#
# The four derived numbers are integrals over the day rather than statistics over
# the samples, and the difference is not academic. The spacing between reports is
# nothing this code should assume — it is whatever the API returns for the range
# asked for, and reports go missing — so a per-sample average silently weights a
# stretch the station was chatty the same as a stretch it was quiet; and clipping
# each *sample* at a threshold charges a whole interval to whichever side its
# endpoints landed on. Both errors are invisible in the output and both compound
# when a season of these gets summed. So: piecewise-linear between real
# timestamps, thresholds crossed at the exact instant the line crosses them.
printf '%s' "$observations" | jq \
--arg date "$day" \
--argjson station "$STATION_ID" \
--argjson start "$start" \
--argjson end "$end" \
--argjson maxgap "$MAX_GAP_SECONDS" '
def mag: if . < 0 then - . else . end;
def fahrenheit: . * 9 / 5 + 32;
def r1: . * 10 | round / 10;
def r2: . * 100 | round / 100;
# Every def below takes a series: [epoch, value] pairs, ascending, one per
# observation that actually carried the field.
# The value of the segment [$a,$b] at time $t.
def at($a; $b; $t): $a[1] + ($b[1] - $a[1]) * ($t - $a[0]) / ($b[0] - $a[0]);
# $p clipped to the day, with the two boundary values interpolated from the
# observations just outside it — which is what the padded fetch above is for.
# A neighbour further out than $maxgap is not used: that is a hole, not a
# boundary, so the series just starts (or ends) at the nearest real sample and
# coverage_hours is left to report the short day.
def day_series($p):
[$p[] | select(.[0] >= $start and .[0] < $end)] as $in
| if ($in | length) == 0 then []
else
([$p[] | select(.[0] < $start)] | last) as $pre
| ([$p[] | select(.[0] >= $end)] | first) as $post
| (if $pre != null and $in[0][0] > $start and ($in[0][0] - $pre[0]) <= $maxgap
then [[$start, at($pre; $in[0]; $start)]] else [] end)
+ $in
+ (if $post != null and $in[-1][0] < $end and ($post[0] - $in[-1][0]) <= $maxgap
then [[$end, at($in[-1]; $post; $end)]] else [] end)
end;
# ∫max(v,0)·dt in value-hours. A segment whose endpoints straddle zero
# contributes only the triangle on the positive side of the crossing, and
# solving for that crossing is the entire reason this is not a trapezoid sum:
# a trapezoid of the clipped endpoints charges the whole interval to the
# positive side, which is how an hour at 31°F becomes an hour of thaw.
def positive_area($s):
reduce range(1; $s | length) as $i (0;
$s[$i - 1][1] as $a
| $s[$i][1] as $b
| (($s[$i][0] - $s[$i - 1][0]) / 3600) as $h
| . + (if $a >= 0 and $b >= 0 then ($a + $b) / 2 * $h
elif $a <= 0 and $b <= 0 then 0
else (([$a, 0] | max) as $pa
| ([$b, 0] | max) as $pb
| ($pa * $pa + $pb * $pb) / (2 * (($a - $b) | mag)) * $h)
end));
# Hours for which the interpolated value is strictly positive, same crossing.
def positive_hours($s):
reduce range(1; $s | length) as $i (0;
$s[$i - 1][1] as $a
| $s[$i][1] as $b
| (($s[$i][0] - $s[$i - 1][0]) / 3600) as $h
| . + (if $a > 0 and $b > 0 then $h
elif $a <= 0 and $b <= 0 then 0
else $h * (([$a, 0] | max) + ([$b, 0] | max)) / (($a - $b) | mag)
end));
# The widest interval between consecutive samples, as [from, to, seconds].
def widest($s):
[range(1; $s | length) | [$s[. - 1][0], $s[.][0], ($s[.][0] - $s[. - 1][0])]]
| max_by(.[2]);
# Refuse rather than draw a straight line across an outage. See MAX_GAP_SECONDS.
def no_gaps($s; $what):
widest($s) as $g
| if $g != null and $g[2] > $maxgap then
"weather-day: \($what) for \($date) stops for \($g[2] / 3600 | r1) h (\($g[0] | localtime | strftime("%H:%M")) to \($g[1] | localtime | strftime("%H:%M"))) — a day total interpolated across a hole that size would look entirely normal and be wrong\n" | halt_error(1)
else . end;
if .status.status_code != 0 then
"weather-day: device observations: \(.status.status_message)\n" | halt_error(1)
else . end
# Sorted and one-per-timestamp because the integrals below walk consecutive
# pairs; the API answers in order, but nothing here should depend on that.
| ([.obs[]? | select(type == "array" and (.[0] | type) == "number")] | unique_by(.[0])) as $all
| [$all[] | select(.[0] >= $start and .[0] < $end)] as $o
| if ($o | length) == 0 then
"weather-day: no observations for \($date) — station offline, or the date is outside its history\n" | halt_error(1)
else . end
| [$o[] | .[7] | numbers] as $temps
| [$o[] | .[12] | numbers] as $precip
| (if ($temps | length) == 0 then
"weather-day: observations for \($date) carry no temperature readings\n" | halt_error(1)
else . end)
| day_series([$all[] | select((.[7] | type) == "number") | [.[0], (.[7] | fahrenheit)]]) as $tempF
| day_series([$all[] | select((.[11] | type) == "number") | [.[0], .[11]]]) as $solar
# A dead pyranometer integrates to a perfectly plausible 0.0 that would be
# written into a note and summed forever, so it is an error, not a zero. Zero
# readings are different, and stay zero.
| (if ($solar | length) == 0 then
"weather-day: observations for \($date) carry no solar radiation readings\n" | halt_error(1)
else . end)
| no_gaps($tempF; "temperature")
| no_gaps($solar; "solar radiation")
# Omitted, not zeroed, on a day that never froze: absent says "no exposure",
# whereas 0.0 is also what a broken calculation says. Tested on the series
# minimum rather than the rounded temp_low, so a midnight boundary that
# interpolates just under 32°F counts — it is inside the day.
| (if ([$tempF[] | .[1]] | min) < 32 then
([$tempF[] | [.[0], (32 - .[1])]] as $freezing
| { hours_below_freezing: (positive_hours($freezing) | r1),
freezing_degree_hours: (positive_area($freezing) | r1) })
else {} end) as $cold
| {
date: $date,
station_id: $station,
temp_high: (($temps | max) | fahrenheit | r1),
temp_low: (($temps | min) | fahrenheit | r1),
rainfall: (($precip | add // 0) / 25.4 | r2),
# Degree-days, so the °F-hours above the base divided by 24 — one day at a
# steady 60°F is 10, not 240. A daily figure, meant to be summed.
gdd_base50: (positive_area([$tempF[] | [.[0], (.[1] - 50)]]) / 24 | r1),
# W/m² integrated over hours is Wh/m². positive_area rather than a plain
# trapezoid because irradiance cannot be negative, and a pyranometer reading
# a little below zero on a clear night should contribute nothing, not debt.
solar_energy_kwh_m2: (positive_area($solar) / 1000 | r2)
}
+ $cold
+ {
observations: ($o | length),
coverage_hours: ((($o | last | .[0]) - ($o | first | .[0])) / 3600 | r1)
}'
+9 -6
View File
@@ -119,22 +119,25 @@
"allowWrite": [ "allowWrite": [
"~/.local/share/pnpm", "~/.local/share/pnpm",
"~/.cache/pnpm", "~/.cache/pnpm",
"~/Documents/Atrium" "~/Documents/Atrium",
"/tachi/docker/atribot/vault"
], ],
"denyRead": [ "denyRead": [
"~/.ssh", "~/.ssh",
"~/.config/Signal", "~/.config/Signal",
"~/Documents" "~/Documents",
"/tachi/backups",
"/tachi/documents",
"/tachi/docker"
], ],
"allowRead": [ "allowRead": [
"~/Documents/Atrium" "~/Documents/Atrium",
"/tachi/docker/atribot/vault"
] ]
}, },
"excludedCommands": [ "excludedCommands": [
"git push *", "git push *",
"brew *", "brew *"
"tn *",
"obsidian *"
] ]
}, },
"spinnerVerbs": { "spinnerVerbs": {
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
input=$(cat)
# Context-window fill from the latest message's usage in the transcript.
transcript=$(echo "$input" | jq -r '.transcript_path // ""')
model_id=$(echo "$input" | jq -r '.model.id // ""')
case "$model_id" in
*1m*|*"[1m]"*) limit=1000000 ;;
*) limit=200000 ;;
esac
used=""
if [ -n "$transcript" ] && [ -f "$transcript" ]; then
used=$(jq -rs '[.[] | select(.message.usage != null) | .message.usage
| (.input_tokens // 0) + (.cache_read_input_tokens // 0)
+ (.cache_creation_input_tokens // 0) + (.output_tokens // 0)]
| last // ""' "$transcript" 2>/dev/null)
fi
# No usage recorded yet (e.g. fresh session before the first turn).
if [ -z "$used" ]; then
exit 0
fi
pct=$(awk -v u="$used" -v l="$limit" 'BEGIN{ printf "%d", (l>0? u*100/l : 0) }')
used_h=$(awk -v n="$used" 'BEGIN{ if(n>=1000000) printf "%.1fM", n/1000000; else printf "%.1fk", n/1000 }')
limit_h=$(awk -v n="$limit" 'BEGIN{ if(n>=1000000) printf "%gM", n/1000000; else printf "%gk", n/1000 }')
if [ "$used" -ge 200000 ]; then ctx_color='1;31'
elif [ "$used" -ge 100000 ]; then ctx_color='1;33'
else ctx_color='1;90'; fi
printf '\033[%sm%s/%s (%d%%)\033[0m' "$ctx_color" "$used_h" "$limit_h" "$pct"
+20 -19
View File
@@ -1,38 +1,39 @@
{ {
"LazyVim": { "branch": "main", "commit": "c10948c50b18fae7f256433afdef09e432410480" }, "LazyVim": { "branch": "main", "commit": "c10948c50b18fae7f256433afdef09e432410480" },
"SchemaStore.nvim": { "branch": "main", "commit": "6ff1f21b2e2b77ec59f7433ce2d9fbc052d908ac" }, "SchemaStore.nvim": { "branch": "main", "commit": "d34c58439271f5e73ed79c2b8d1a09731c4525f1" },
"blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" }, "blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" },
"bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" }, "bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" },
"catppuccin": { "branch": "main", "commit": "e068ab5f8261f23f6f71ffd8791ae40315b77b9c" }, "catppuccin": { "branch": "main", "commit": "edefef779ab08ce1a4a404713e3012b0d202bd35" },
"conform.nvim": { "branch": "master", "commit": "619363c30309d29ffa631e67c8183f2a72caa373" }, "claude-review.nvim": { "branch": "main", "commit": "899a33a04d26b6a75384dda176e212eee75a2532" },
"diffview.nvim": { "branch": "main", "commit": "bcf4b62b4acc36a7c3d19e423713a220c838a668" }, "conform.nvim": { "branch": "master", "commit": "016802de402556da54c36bd7359b441266b01cdd" },
"flash.nvim": { "branch": "main", "commit": "fcea7ff883235d9024dc41e638f164a450c14ca2" }, "diffview.nvim": { "branch": "main", "commit": "43e60bca414e4991ed10118e59f809fb03bbeddd" },
"flash.nvim": { "branch": "main", "commit": "5f0f270fdc7c5b0c21d903ee85b9cb06f2ac636a" },
"friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" }, "friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
"gitsigns.nvim": { "branch": "main", "commit": "42d6aed4e94e0f0bbced16bbdcc42f57673bd75e" }, "gitsigns.nvim": { "branch": "main", "commit": "42d6aed4e94e0f0bbced16bbdcc42f57673bd75e" },
"grug-far.nvim": { "branch": "main", "commit": "c69859c1d5427ab5fc7ed12380ab521b4e336691" }, "grug-far.nvim": { "branch": "main", "commit": "11595bf747edc270bce2069d1020502ad4ae56cf" },
"lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" }, "lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" },
"lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" }, "lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" },
"lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" }, "lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" },
"markdown-preview.nvim": { "branch": "master", "commit": "a923f5fc5ba36a3b17e289dc35dc17f66d0548ee" }, "markdown-preview.nvim": { "branch": "master", "commit": "a923f5fc5ba36a3b17e289dc35dc17f66d0548ee" },
"mason-lspconfig.nvim": { "branch": "main", "commit": "47059d71b42d74b0a1e9f61c1d99d301039c3b5b" }, "mason-lspconfig.nvim": { "branch": "main", "commit": "40276c4df7e6bdce6801d6c035c6227f9115a855" },
"mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" }, "mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" },
"mini.ai": { "branch": "main", "commit": "cb20f298ebf5ae91924cd0c6c310712de2ef4086" }, "mini.ai": { "branch": "main", "commit": "25248c6aa002391936a6200f12d1466015987133" },
"mini.diff": { "branch": "main", "commit": "0743d26bd858ebe32efcf5c86a91a422a000f273" }, "mini.diff": { "branch": "main", "commit": "626b8a5b93874c4d05ca25aedec56cfff0b378fb" },
"mini.icons": { "branch": "main", "commit": "24dbea2195c477e57d581215839a6ab915f34b14" }, "mini.icons": { "branch": "main", "commit": "98faae31e9be1cc054ae63485e58ceb185efcad0" },
"mini.pairs": { "branch": "main", "commit": "fd150ac39b78e6a2286f5138e472b7dc7eba43b9" }, "mini.pairs": { "branch": "main", "commit": "b1c5a726921b7a8c9321e9a7a208aa0571de5810" },
"mini.surround": { "branch": "main", "commit": "a2f644f3759edd3d3f8b6a6d55378408bfe6d290" }, "mini.surround": { "branch": "main", "commit": "8d5d0c5aa92449368ac251e85451d79d8f69d296" },
"noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" }, "noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" },
"nui.nvim": { "branch": "main", "commit": "de740991c12411b663994b2860f1a4fd0937c130" }, "nui.nvim": { "branch": "main", "commit": "10fc361835c856ba4233ef5ea135b919bf3dce97" },
"nvim-ansible": { "branch": "main", "commit": "c7f595d568b588942d4d0c37b5cd6cae3764a148" }, "nvim-ansible": { "branch": "main", "commit": "c7f595d568b588942d4d0c37b5cd6cae3764a148" },
"nvim-lint": { "branch": "master", "commit": "a219b2c9e5b4765e5c845aba119dad55806fcaf1" }, "nvim-lint": { "branch": "master", "commit": "3d55c8f67c6ae5c15e1042571e107c7a3d5c5f4e" },
"nvim-lspconfig": { "branch": "master", "commit": "292f44408498103c47996ff5c18fd366293840d8" }, "nvim-lspconfig": { "branch": "master", "commit": "16286347bdba1333c7d124d9de9fe6630731b2b2" },
"nvim-treesitter": { "branch": "main", "commit": "4916d6592ede8c07973490d9322f187e07dfefac" }, "nvim-treesitter": { "branch": "main", "commit": "19071296d3d643b48615ee574a20e8a03ac40872" },
"nvim-treesitter-textobjects": { "branch": "main", "commit": "851e865342e5a4cb1ae23d31caf6e991e1c99f1e" }, "nvim-treesitter-textobjects": { "branch": "main", "commit": "898ee307df58f854d11cd7edd06472574d48014e" },
"nvim-ts-autotag": { "branch": "main", "commit": "88c1453db4ba7dd24131086fe51fdf74e587d275" }, "nvim-ts-autotag": { "branch": "main", "commit": "88c1453db4ba7dd24131086fe51fdf74e587d275" },
"octo.nvim": { "branch": "master", "commit": "b9a73e167f851a98d8f29d62658d3640bb8a7314" }, "octo.nvim": { "branch": "master", "commit": "af2411604b51cb4a0f3e2de50b1b7cacc2581c48" },
"persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" }, "persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
"plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" }, "plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" },
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" }, "render-markdown.nvim": { "branch": "main", "commit": "4663eb3ecd538bd5062628fb6d95bbe6bdca78f6" },
"sidekick.nvim": { "branch": "main", "commit": "208e1c5b8170c01fd1d07df0139322a76479b235" }, "sidekick.nvim": { "branch": "main", "commit": "208e1c5b8170c01fd1d07df0139322a76479b235" },
"snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" }, "snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" },
"todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" }, "todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" },
@@ -8,7 +8,7 @@ return {
}, },
}, },
{ {
dir = "/home/tgrosinger/code/claude-review", url = "ssh://git@git.grosinger.net:22322/tgrosinger/claude-review.nvim.git",
cmd = "ClaudeReview", cmd = "ClaudeReview",
dependencies = { dependencies = {
"dlyongemallo/diffview.nvim", "dlyongemallo/diffview.nvim",
+1
View File
@@ -0,0 +1 @@
../lib/dev-tickets/bin/ticket.mjs
-6
View File
@@ -1,6 +0,0 @@
#!/bin/sh
# Run tn under the global (lts) node, bypassing the mise shim which
# would otherwise resolve node from the current directory's pin.
# Also handles tn-fzf: symlink it to this script; dispatches on $0.
bin="$HOME/.local/share/mise/installs/node/lts/bin"
PATH="$bin:$PATH" exec "$bin/${0##*/}" "$@"
-1
View File
@@ -1 +0,0 @@
tn
+21
View File
@@ -0,0 +1,21 @@
#!/usr/bin/env -S MISE_NODE_VERSION=lts node
// Entry point for `ticket`. Kept tiny so a missing install fails with an
// instruction rather than a stack trace. The shebang pins mise's lts Node so
// the tool runs the same from inside any repo, whatever that repo pins.
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const packageDir = resolve(dirname(fileURLToPath(import.meta.url)), '..');
let main;
try {
({ main } = await import('../src/cli.mjs'));
} catch (err) {
if (err?.code === 'ERR_MODULE_NOT_FOUND' && /@callumalpass\/mdbase/.test(String(err.message))) {
process.stderr.write(`ticket: dependencies are not installed.\nRun: npm ci --prefix ${packageDir}\n`);
process.exit(1);
}
throw err;
}
process.exitCode = await main(process.argv.slice(2));
+227
View File
@@ -0,0 +1,227 @@
{
"name": "dev-tickets",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "dev-tickets",
"version": "0.1.0",
"dependencies": {
"@callumalpass/mdbase": "0.2.2"
},
"bin": {
"ticket": "bin/ticket.mjs"
}
},
"node_modules/@callumalpass/mdbase": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/@callumalpass/mdbase/-/mdbase-0.2.2.tgz",
"integrity": "sha512-o2htBbIMG4q/lhQX9HU3YBWD4JBeD4xruYB4oIzxzaAZ+m/+Q7IgEW06NxizSIhp0KbnE78pwzcd3ABY+ztpSw==",
"dependencies": {
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.0",
"picomatch": "^4.0.0",
"sql.js": "^1.12.0",
"ulid": "^2.3.0",
"uuid": "^10.0.0",
"yaml": "^2.8.2"
},
"bin": {
"mdb-profile": "dist/bin/mdb-profile.js"
}
},
"node_modules/argparse": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz",
"integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==",
"license": "Python-2.0"
},
"node_modules/esprima": {
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/esprima/-/esprima-4.0.1.tgz",
"integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==",
"license": "BSD-2-Clause",
"bin": {
"esparse": "bin/esparse.js",
"esvalidate": "bin/esvalidate.js"
},
"engines": {
"node": ">=4"
}
},
"node_modules/extend-shallow": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/extend-shallow/-/extend-shallow-2.0.1.tgz",
"integrity": "sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==",
"license": "MIT",
"dependencies": {
"is-extendable": "^0.1.0"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/gray-matter": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/gray-matter/-/gray-matter-4.0.3.tgz",
"integrity": "sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==",
"license": "MIT",
"dependencies": {
"js-yaml": "^3.13.1",
"kind-of": "^6.0.2",
"section-matter": "^1.0.0",
"strip-bom-string": "^1.0.0"
},
"engines": {
"node": ">=6.0"
}
},
"node_modules/gray-matter/node_modules/argparse": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz",
"integrity": "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==",
"license": "MIT",
"dependencies": {
"sprintf-js": "~1.0.2"
}
},
"node_modules/gray-matter/node_modules/js-yaml": {
"version": "3.15.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-3.15.2.tgz",
"integrity": "sha512-6EuL879VkRA+1Cz578mKMiKvjPNEuk6+r1JaFzoSWejZmtf7xWbIyw1e3KkxlkzTIt9Taw6JBhEppG7utc1P+w==",
"license": "MIT",
"dependencies": {
"argparse": "^1.0.7",
"esprima": "^4.0.0"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/is-extendable": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/is-extendable/-/is-extendable-0.1.1.tgz",
"integrity": "sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/js-yaml": {
"version": "4.3.2",
"resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz",
"integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/puzrin"
},
{
"type": "github",
"url": "https://github.com/sponsors/nodeca"
}
],
"license": "MIT",
"dependencies": {
"argparse": "^2.0.1"
},
"bin": {
"js-yaml": "bin/js-yaml.js"
}
},
"node_modules/kind-of": {
"version": "6.0.3",
"resolved": "https://registry.npmjs.org/kind-of/-/kind-of-6.0.3.tgz",
"integrity": "sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/section-matter": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/section-matter/-/section-matter-1.0.0.tgz",
"integrity": "sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==",
"license": "MIT",
"dependencies": {
"extend-shallow": "^2.0.1",
"kind-of": "^6.0.0"
},
"engines": {
"node": ">=4"
}
},
"node_modules/sprintf-js": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/sprintf-js/-/sprintf-js-1.0.3.tgz",
"integrity": "sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==",
"license": "BSD-3-Clause"
},
"node_modules/sql.js": {
"version": "1.14.2",
"resolved": "https://registry.npmjs.org/sql.js/-/sql.js-1.14.2.tgz",
"integrity": "sha512-3ZGPovObMFrdw79zrUHbfdE/DLIsy8jdNdssmMSQuRAymedU6q84asPt0kgiqrdMYlPegDItiIMfmIXzZnYFcw==",
"license": "MIT"
},
"node_modules/strip-bom-string": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/strip-bom-string/-/strip-bom-string-1.0.0.tgz",
"integrity": "sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/ulid": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/ulid/-/ulid-2.4.0.tgz",
"integrity": "sha512-fIRiVTJNcSRmXKPZtGzFQv9WRrZ3M9eoptl/teFJvjOzmpU+/K/JH6HZ8deBfb5vMEpicJcLn7JmvdknlMq7Zg==",
"license": "MIT",
"bin": {
"ulid": "bin/cli.js"
}
},
"node_modules/uuid": {
"version": "10.0.0",
"resolved": "https://registry.npmjs.org/uuid/-/uuid-10.0.0.tgz",
"integrity": "sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==",
"deprecated": "uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028).",
"funding": [
"https://github.com/sponsors/broofa",
"https://github.com/sponsors/ctavan"
],
"license": "MIT",
"bin": {
"uuid": "dist/bin/uuid"
}
},
"node_modules/yaml": {
"version": "2.9.0",
"resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz",
"integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==",
"license": "ISC",
"bin": {
"yaml": "bin.mjs"
},
"engines": {
"node": ">= 14.6"
},
"funding": {
"url": "https://github.com/sponsors/eemeli"
}
}
}
}
+16
View File
@@ -0,0 +1,16 @@
{
"name": "dev-tickets",
"version": "0.1.0",
"private": true,
"description": "ticket: a file-based CLI for dev tickets stored as TaskNotes tasks in an mdbase collection",
"type": "module",
"bin": {
"ticket": "bin/ticket.mjs"
},
"scripts": {
"test": "node --test \"test/*.test.mjs\""
},
"dependencies": {
"@callumalpass/mdbase": "0.2.2"
}
}
+86
View File
@@ -0,0 +1,86 @@
// Edits to a note's body that keep the model's shape: a running log under
// `## Notes` (dated `### [[YYYY-MM-DD]]` sub-headings) sitting above a
// trailing `## As Built`. Pure functions over text; the caller writes.
export const NOTES_HEADING = '## Notes';
export const AS_BUILT_HEADING = '## As Built';
const DATED_HEADING = /^### \[\[(\d{4}-\d{2}-\d{2})\]\]\s*$/;
const isSectionHeading = (line) => /^## /.test(line);
function findLine(lines, heading, from = 0, to = lines.length) {
for (let i = from; i < to; i++) if (lines[i].trim() === heading) return i;
return -1;
}
function trimBlankEdges(lines) {
let start = 0;
let end = lines.length;
while (start < end && lines[start].trim() === '') start++;
while (end > start && lines[end - 1].trim() === '') end--;
return lines.slice(start, end);
}
function join(lines) {
return `${lines.join('\n').replace(/\n+$/, '')}\n`;
}
/**
* Append `text` to the running log. The entry goes under the last dated
* heading in `## Notes` when that heading is `date`, otherwise under a new
* `### [[date]]`. Returns the new body and a warning when the sections the
* model expects were missing and had to be added.
*/
export function appendNote(body, text, date) {
const lines = body.replace(/\s+$/, '').split('\n');
let warning;
let asBuilt = findLine(lines, AS_BUILT_HEADING);
if (asBuilt === -1) {
warning = `no "${AS_BUILT_HEADING}" section; the note was appended at the end of the file`;
asBuilt = lines.length;
}
let notes = findLine(lines, NOTES_HEADING, 0, asBuilt);
if (notes === -1) {
warning ??= `no "${NOTES_HEADING}" section; one was added above "${AS_BUILT_HEADING}"`;
let insertAt = asBuilt;
while (insertAt > 0 && lines[insertAt - 1].trim() === '') insertAt--;
lines.splice(insertAt, asBuilt - insertAt, '', NOTES_HEADING);
notes = insertAt + 1;
asBuilt = notes + 1;
}
const section = trimBlankEdges(lines.slice(notes + 1, asBuilt));
const lastHeading = section.map((l) => l.match(DATED_HEADING)?.[1]).filter(Boolean).pop();
const entry = text.trim();
const extended = lastHeading === date
? [...section, '', entry]
: [...section, ...(section.length ? [''] : []), `### [[${date}]]`, '', entry];
const out = [...lines.slice(0, notes + 1), '', ...extended, '', ...lines.slice(asBuilt)];
return { body: join(out), warning };
}
/** Append `text` at the end of `## As Built`, adding the section when it is missing. */
export function appendAsBuilt(body, text) {
const lines = body.replace(/\s+$/, '').split('\n');
let warning;
let start = findLine(lines, AS_BUILT_HEADING);
if (start === -1) {
warning = `no "${AS_BUILT_HEADING}" section; one was added at the end of the file`;
lines.push('', AS_BUILT_HEADING);
start = lines.length - 1;
}
let end = start + 1;
while (end < lines.length && !isSectionHeading(lines[end])) end++;
const section = trimBlankEdges(lines.slice(start + 1, end));
const out = [...lines.slice(0, start + 1), '', ...section, ...(section.length ? [''] : []), text.trim(), '', ...lines.slice(end)];
return { body: join(out), warning };
}
/** Make sure a body ends with the two trailing sections every effort and ticket carries. */
export function ensureSections(body) {
const lines = body.replace(/\s+$/, '').split('\n');
const missing = [NOTES_HEADING, AS_BUILT_HEADING].filter((h) => findLine(lines, h) === -1);
if (missing.length === 0) return join(lines);
const out = [...lines];
for (const heading of missing) out.push('', heading);
return join(trimBlankEdges(out));
}
+374
View File
@@ -0,0 +1,374 @@
// Verb dispatch, argument parsing, and output. Each verb is a small function
// over `Vault`; this file owns only what is common to all of them.
import { parseArgs } from 'node:util';
import { readFileSync } from 'node:fs';
import { appendAsBuilt, appendNote } from './body.mjs';
import * as lifecycle from './lifecycle.mjs';
import { create } from './create.mjs';
import { DATE_PATTERN, localDate } from './time.mjs';
import { join, resolve } from 'node:path';
import { TicketError, refusal, usage } from './errors.mjs';
import { expandHome, resolveVaultPath, writeVaultPath } from './config.mjs';
import { ACTOR_TAGS, DEV_ROOT, STALL_TAG, STATUS, Vault, actorOf, andWhere, childOfWhere, hasTag, isEffortPath, linkTarget } from './vault.mjs';
const GLOBAL_OPTIONS = {
vault: { type: 'string' },
json: { type: 'boolean', default: false },
help: { type: 'boolean', short: 'h', default: false },
};
const VERBS = {
vault: {
summary: 'vault [--set PATH]',
describe: 'print the vault path, or record it for this host',
options: { set: { type: 'string' } },
run: cmdVault,
},
show: {
summary: 'show REF',
describe: 'print a note verbatim (--json: its record with body)',
options: {},
run: cmdShow,
},
list: {
summary: 'list [--effort E] [--repo R] [--open] [--where EXPR] [--all]',
describe: 'tasks under TaskNotes/Dev (or the whole vault with --all)',
options: {
effort: { type: 'string' },
repo: { type: 'string' },
open: { type: 'boolean', default: false },
where: { type: 'string' },
all: { type: 'boolean', default: false },
},
run: cmdList,
},
note: {
summary: 'note REF TEXT|--file F [--date D]',
describe: 'append to the running log under ## Notes, above ## As Built',
options: { file: { type: 'string' }, date: { type: 'string' } },
run: cmdNote,
},
'as-built': {
summary: 'as-built REF TEXT|--file F',
describe: 'append under ## As Built',
options: { file: { type: 'string' } },
run: cmdAsBuilt,
},
frontier: {
summary: 'frontier EFFORT [--actor agent|human]',
describe: "the effort's tickets that are Open and unblocked (agent: also agent-step and not needs-info)",
options: { actor: { type: 'string' } },
run: cmdFrontier,
},
inbox: {
summary: 'inbox',
describe: 'what waits on a human: human-step or needs-info, not completed, vault-wide',
options: {},
run: cmdInbox,
},
create: {
summary: 'create --repo R|--effort E --title T ...',
describe: 'an effort (--repo R --context C) or a ticket (--effort E --type T --actor agent|human [--blocked-by B])',
options: {
repo: { type: 'string' },
effort: { type: 'string' },
title: { type: 'string' },
context: { type: 'string', multiple: true },
type: { type: 'string' },
actor: { type: 'string' },
'blocked-by': { type: 'string', multiple: true },
status: { type: 'string' },
tag: { type: 'string', multiple: true },
set: { type: 'string', multiple: true },
'body-file': { type: 'string' },
},
run: cmdCreate,
},
claim: {
summary: 'claim REF [--session ID] [--force]',
describe: 'take a ticket: In Progress under this session',
options: { session: { type: 'string' }, force: { type: 'boolean', default: false } },
run: cmdClaim,
},
stall: {
summary: 'stall REF TEXT|--file F',
describe: 'mark needs-info and log the question',
options: { file: { type: 'string' } },
run: cmdStall,
},
unstall: {
summary: 'unstall REF [TEXT|--file F]',
describe: 'clear needs-info, optionally logging the answer',
options: { file: { type: 'string' } },
run: cmdUnstall,
},
close: {
summary: 'close REF [--wont-do] [--actor agent|human]',
describe: 'finish (or abandon) a ticket; correct the actor if the other one did the work',
options: { 'wont-do': { type: 'boolean', default: false }, actor: { type: 'string' } },
run: cmdClose,
},
set: {
summary: 'set REF k=v k= k+=v k-=v ...',
describe: 'edit frontmatter; += and -= on tags, contexts, projects, blockedBy',
options: {},
run: cmdSet,
},
};
export async function main(argv) {
try {
const [name, ...rest] = argv;
if (!name || name === '--help' || name === '-h') {
process.stdout.write(usageText());
return name ? 0 : 1;
}
const verb = VERBS[name];
if (!verb) throw usage(`unknown verb "${name}"\n\n${usageText()}`);
const { values, positionals } = parseArgs({
args: rest,
options: { ...GLOBAL_OPTIONS, ...verb.options },
allowPositionals: true,
strict: true,
});
if (values.help) {
process.stdout.write(`ticket ${verb.summary}\n ${verb.describe}\n`);
return 0;
}
await verb.run(values, positionals);
return 0;
} catch (err) {
if (err instanceof TicketError) {
process.stderr.write(`ticket: ${err.message}\n`);
return err.exitCode;
}
if (typeof err?.code === 'string' && err.code.startsWith('ERR_PARSE_ARGS')) {
process.stderr.write(`ticket: ${err.message}\n`);
return 1;
}
throw err;
}
}
function usageText() {
const width = Math.max(...Object.values(VERBS).map((v) => v.summary.length));
const lines = Object.values(VERBS).map((v) => ` ticket ${v.summary.padEnd(width)} ${v.describe}`);
return `usage:\n${lines.join('\n')}\n\nglobal: --vault PATH --json --help\n`;
}
// ---- output ---------------------------------------------------------------
const warn = (message) => process.stderr.write(`ticket: warning: ${message}\n`);
/** Print `text`, or `json` when --json was given. */
function emit(values, json, text) {
process.stdout.write(values.json ? `${JSON.stringify(json, null, 2)}\n` : `${text}\n`);
}
/** One row per record: status (with blocked / needs-info flags), actor, title, path. */
function rowOf(record) {
const flags = [];
if (record.isBlocked) flags.push('blocked');
if (hasTag(record, STALL_TAG)) flags.push(STALL_TAG);
const status = `${record.status ?? '-'}${flags.length ? ` (${flags.join(', ')})` : ''}`;
return [status, actorOf(record) ?? '-', record.title, record.path];
}
function emitRecords(values, records) {
if (values.json) return emit(values, records);
if (records.length === 0) return;
const rows = records.map(rowOf);
const widths = rows[0].map((_, i) => Math.max(...rows.map((r) => r[i].length)));
const text = rows.map((r) => r.map((cell, i) => (i === r.length - 1 ? cell : cell.padEnd(widths[i]))).join(' ')).join('\n');
process.stdout.write(`${text}\n`);
}
/** Open the configured vault for the duration of `fn`. */
async function withVault(values, fn) {
const vault = await Vault.open(resolveVaultPath(values.vault), { warn });
try {
return await fn(vault);
} finally {
await vault.close();
}
}
/** The text a verb appends: the positional after REF, or --file (`-` for stdin), never both. */
function requireText(values, positionals, verb, { optional = false } = {}) {
const fromArg = positionals[1];
if (values.file !== undefined && fromArg !== undefined) throw usage(`${verb}: give TEXT or --file, not both`);
if (values.file === undefined && fromArg === undefined) {
if (optional) return undefined;
throw usage(`${verb}: TEXT or --file is required`);
}
const text = values.file === undefined ? fromArg : readFileSync(values.file === '-' ? 0 : values.file, 'utf8');
if (text.trim() === '') throw usage(`${verb}: the text is empty`);
return text;
}
/** Resolve REF to a task note, refusing anything else: mutations never touch non-tasks. */
async function taskNote(vault, ref) {
const note = await vault.readNote(vault.resolve(ref));
if (!vault.isTask(note)) throw refusal(`${note.path} is not a task (no "task" tag); refusing to modify it`, 'not_a_task');
return note;
}
async function emitWritten(values, vault, note) {
emitRecords(values, [await vault.record(note)]);
}
function requireRef(positionals, verb) {
if (positionals.length < 1) throw usage(`${verb} needs a note reference (a path or a title)`);
return positionals[0];
}
// ---- verbs ----------------------------------------------------------------
async function cmdVault(values) {
let path;
if (values.set !== undefined) {
path = resolve(expandHome(values.set));
let vault;
try {
vault = await Vault.open(path);
} catch (err) {
if (err instanceof TicketError) throw refusal(`refusing to record ${err.message}`, err.code);
throw err;
}
await vault.close();
writeVaultPath(path);
} else {
path = resolveVaultPath(values.vault);
}
emit(values, { vault: path }, path);
}
async function cmdShow(values, positionals) {
const ref = requireRef(positionals, 'show');
await withVault(values, async (vault) => {
const path = vault.resolve(ref);
if (!values.json) {
process.stdout.write(readFileSync(join(vault.root, path), 'utf8'));
return;
}
emit(values, await vault.record(await vault.readNote(path), { body: true }));
});
}
async function cmdList(values) {
await withVault(values, async (vault) => {
const parts = [values.where];
if (values.effort) parts.push(childOfWhere(vault.titleOf(values.effort)));
if (values.repo) parts.push(childOfWhere(linkTarget(values.repo)));
if (values.open) parts.push(vault.openWhere());
const records = await vault.records({ where: andWhere(...parts), folder: values.all ? undefined : DEV_ROOT });
emitRecords(values, records);
});
}
async function cmdNote(values, positionals) {
const ref = requireRef(positionals, 'note');
const text = requireText(values, positionals, 'note');
const date = values.date ?? localDate();
if (!DATE_PATTERN.test(date)) throw usage(`--date must be YYYY-MM-DD, got "${date}"`);
await withVault(values, async (vault) => {
const note = await taskNote(vault, ref);
const { body, warning } = appendNote(note.body, text, date);
if (warning) warn(`${note.path}: ${warning}`);
await emitWritten(values, vault, await vault.write(note, { body }));
});
}
async function cmdAsBuilt(values, positionals) {
const ref = requireRef(positionals, 'as-built');
const text = requireText(values, positionals, 'as-built');
await withVault(values, async (vault) => {
const note = await taskNote(vault, ref);
const { body, warning } = appendAsBuilt(note.body, text);
if (warning) warn(`${note.path}: ${warning}`);
await emitWritten(values, vault, await vault.write(note, { body }));
});
}
/** Apply a lifecycle change: write its fields/body, surface its warnings, print the record. */
async function applyChange(values, vault, note, change) {
for (const warning of change.warnings) warn(`${note.path}: ${warning}`);
await emitWritten(values, vault, await vault.write(note, { fields: change.fields, body: change.body }));
}
async function cmdClaim(values, positionals) {
const ref = requireRef(positionals, 'claim');
const session = values.session ?? process.env.CLAUDE_CODE_SESSION_ID;
if (!session) throw usage('claim needs a session id: pass --session or set CLAUDE_CODE_SESSION_ID');
await withVault(values, async (vault) => {
const note = await taskNote(vault, ref);
await applyChange(values, vault, note, await lifecycle.claim(vault, note, { session, force: values.force }));
});
}
async function cmdStall(values, positionals) {
const ref = requireRef(positionals, 'stall');
const text = requireText(values, positionals, 'stall');
await withVault(values, async (vault) => {
const note = await taskNote(vault, ref);
await applyChange(values, vault, note, lifecycle.stall(note, text));
});
}
async function cmdUnstall(values, positionals) {
const ref = requireRef(positionals, 'unstall');
const text = requireText(values, positionals, 'unstall', { optional: true });
await withVault(values, async (vault) => {
const note = await taskNote(vault, ref);
await applyChange(values, vault, note, lifecycle.unstall(note, text));
});
}
async function cmdClose(values, positionals) {
const ref = requireRef(positionals, 'close');
await withVault(values, async (vault) => {
const note = await taskNote(vault, ref);
await applyChange(values, vault, note, await lifecycle.close(vault, note, { wontDo: values['wont-do'], actor: values.actor }));
});
}
async function cmdSet(values, positionals) {
const ref = requireRef(positionals, 'set');
await withVault(values, async (vault) => {
const note = await taskNote(vault, ref);
await applyChange(values, vault, note, await lifecycle.set(vault, note, positionals.slice(1)));
});
}
async function cmdCreate(values, positionals) {
if (positionals.length) throw usage(`create takes flags only (unexpected: ${positionals.join(' ')})`);
const body = values['body-file'] === undefined ? '' : readFileSync(values['body-file'] === '-' ? 0 : values['body-file'], 'utf8');
await withVault(values, async (vault) => {
const { note, warnings } = await create(vault, values, body);
for (const warning of warnings) warn(`${note.path}: ${warning}`);
await emitWritten(values, vault, note);
});
}
async function cmdFrontier(values, positionals) {
const ref = requireRef(positionals, 'frontier');
const actor = values.actor;
const actorTag = Object.entries(ACTOR_TAGS).find(([, word]) => word === actor)?.[0];
if (actor !== undefined && !actorTag) throw usage(`--actor must be agent or human (got "${actor}")`);
await withVault(values, async (vault) => {
const effort = await vault.readNote(vault.resolve(ref));
if (!vault.isTask(effort) || !isEffortPath(effort.path)) throw refusal(`${effort.path} is not an effort`, 'not_an_effort');
const open = await vault.records({ where: andWhere(childOfWhere(effort.title), `status == ${JSON.stringify(STATUS.open)}`), folder: DEV_ROOT });
const frontier = open.filter((r) => !r.isBlocked && (!actorTag || hasTag(r, actorTag)) && !(actor === 'agent' && hasTag(r, STALL_TAG)));
emitRecords(values, frontier);
});
}
async function cmdInbox(values, positionals) {
if (positionals.length) throw usage('inbox takes no arguments');
await withVault(values, async (vault) => {
const waiting = Object.keys(ACTOR_TAGS).filter((tag) => ACTOR_TAGS[tag] === 'human').concat(STALL_TAG);
const where = andWhere(waiting.map((tag) => `tags.contains(${JSON.stringify(tag)})`).join(' || '), vault.openWhere());
emitRecords(values, await vault.records({ where }));
});
}
@@ -0,0 +1,42 @@
// Where the vault is. The order mirrors mdbase-tasknotes (`mtn`) so the two
// could never disagree: flag, then environment, then its config file. There is
// deliberately no current-directory fallback — an agent running inside a repo
// must never treat that repo as a collection.
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { failure, usage } from './errors.mjs';
export const CONFIG_FILE = join(homedir(), '.config', 'mdbase-tasknotes', 'config.json');
export const ENV_VAR = 'MDBASE_TASKNOTES_PATH';
export function expandHome(path) {
if (path === '~') return homedir();
if (path.startsWith('~/')) return join(homedir(), path.slice(2));
return path;
}
function readConfig() {
try {
return JSON.parse(readFileSync(CONFIG_FILE, 'utf8'));
} catch (err) {
if (err.code === 'ENOENT') return {};
throw failure(`${CONFIG_FILE}: ${err.message}`, 'bad_config');
}
}
export function resolveVaultPath(flagValue) {
if (flagValue) return resolve(expandHome(flagValue));
const fromEnv = process.env[ENV_VAR];
if (fromEnv) return resolve(expandHome(fromEnv));
const { collectionPath } = readConfig();
if (collectionPath) return resolve(expandHome(collectionPath));
throw usage(`no vault configured: pass --vault, set ${ENV_VAR}, or run \`ticket vault --set <path>\` once (writes ${CONFIG_FILE})`);
}
export function writeVaultPath(path) {
const config = readConfig();
config.collectionPath = path;
mkdirSync(dirname(CONFIG_FILE), { recursive: true });
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2) + '\n');
}
+120
View File
@@ -0,0 +1,120 @@
// Creating an effort or a ticket. The tool owns the shape
// (`TaskNotes/Dev/<repo>/<effort>/`), so the folder, the parent link, and —
// for a ticket — the contexts are derived, never passed. Nothing is written
// until every check has passed, so a note is never half-written.
import { dirname, join } from 'node:path';
import { ensureSections } from './body.mjs';
import { failure, refusal, usage } from './errors.mjs';
import { set as setFields } from './lifecycle.mjs';
import { localIso } from './time.mjs';
import { ACTOR_TAGS, DEV_ROOT, RELTYPE, STATUS, TASK_TAG, isEffortPath, linkTarget, titleOf, wikilink } from './vault.mjs';
/** The model's ticket types. `setup` is groundwork whose result later tickets depend on. */
export const TICKET_TYPES = ['research', 'prototype', 'grilling', 'setup', 'implementation'];
const ACTOR_WORDS = Object.fromEntries(Object.entries(ACTOR_TAGS).map(([tag, word]) => [word, tag]));
/** Keys `create` derives or takes as flags; --set may not touch them. */
const FLAG_KEYS = ['status', 'priority', 'contexts', 'projects', 'tags', 'blockedBy', 'date_created', 'date_modified', 'date_completed', 'date_scheduled', 'ticket_type'];
const BAD_TITLE = /[\\/:*?"<>|#^[\]]/;
function requireTitle(title) {
if (!title || title.trim() === '') throw usage('--title is required');
const clean = title.trim();
if (BAD_TITLE.test(clean)) throw usage(`--title may not contain any of \\ / : * ? " < > | # ^ [ ] (got "${clean}")`);
if (clean.endsWith('.md')) throw usage('--title is a title, not a filename; drop the .md');
return clean;
}
/** Where a new note goes, and what its parent link is, for an effort (--repo) or a ticket (--effort). */
async function placement(vault, values, title) {
if (values.repo && values.effort) throw usage('give --repo (to create an effort) or --effort (to create a ticket), not both');
if (values.repo) {
const repo = linkTarget(values.repo);
if (BAD_TITLE.test(repo)) throw usage(`--repo may not contain path characters (got "${repo}")`);
const existing = [...vault.files()].map((p) => p.split('/')).filter((s) => s.length > 2 && `${s[0]}/${s[1]}` === DEV_ROOT).map((s) => s[2]);
const twin = existing.find((dir) => dir !== repo && dir.toLowerCase() === repo.toLowerCase());
if (twin) throw refusal(`repo directory "${twin}" already exists under ${DEV_ROOT}; use that spelling`, 'repo_case');
return { kind: 'effort', folder: `${DEV_ROOT}/${repo}/${title}`, parent: wikilink(repo), contexts: values.context ?? [] };
}
if (values.effort) {
const effort = await vault.readNote(vault.resolve(values.effort));
if (!vault.isTask(effort) || !isEffortPath(effort.path)) {
throw refusal(`${effort.path} is not an effort (efforts sit at ${DEV_ROOT}/<repo>/<effort>/<effort>.md)`, 'not_an_effort');
}
const contexts = Array.isArray(effort.frontmatter.contexts) ? effort.frontmatter.contexts : [];
return { kind: 'ticket', folder: dirname(effort.path), parent: wikilink(effort.title), contexts };
}
throw usage('give --repo (to create an effort) or --effort (to create a ticket)');
}
function requireFlags(kind, values) {
const forbidden = (flag, why) => {
if (values[flag] !== undefined && !(Array.isArray(values[flag]) && values[flag].length === 0)) throw usage(`--${flag} ${why}`);
};
if (kind === 'effort') {
if (!values.context?.length) throw usage('--context is required for an effort (from the repo\'s CLAUDE.local.md)');
forbidden('type', 'is for tickets; an effort has no ticket_type');
forbidden('actor', 'is for tickets; an effort carries no actor tag');
forbidden('blocked-by', 'is for tickets; efforts are not blocked');
return {};
}
forbidden('context', 'is not accepted for a ticket; contexts are inherited from the effort');
if (!values.type) throw usage(`--type is required for a ticket: ${TICKET_TYPES.join(', ')}`);
if (!TICKET_TYPES.includes(values.type)) throw usage(`--type must be one of ${TICKET_TYPES.join(', ')} (got "${values.type}")`);
if (!values.actor) throw usage('--actor is required for a ticket: agent or human');
if (!ACTOR_WORDS[values.actor]) throw usage(`--actor must be agent or human (got "${values.actor}")`);
return { ticket_type: values.type, actorTag: ACTOR_WORDS[values.actor] };
}
async function resolveBlockers(vault, refs) {
const blockers = [];
for (const ref of refs ?? []) {
const target = await vault.readNote(vault.resolve(ref));
if (!vault.isTask(target)) throw refusal(`${target.path} is not a task; a blocker must be one`, 'not_a_task');
if (!blockers.some((b) => linkTarget(b.uid).toLowerCase() === target.title.toLowerCase())) {
blockers.push({ uid: wikilink(target.title), reltype: RELTYPE });
}
}
return blockers;
}
function refuseCollisions(vault, path, title) {
if (vault.files().has(path)) throw refusal(`${path} already exists`, 'exists');
const twins = vault.pathsTitled(title);
if (twins.length) throw refusal(`a note titled "${title}" already exists (titles bind case-insensitively, vault-wide):\n ${twins.join('\n ')}`, 'title_taken');
}
/** Build and write the note; returns the new note as read back from disk. */
export async function create(vault, values, body) {
const title = requireTitle(values.title);
const { kind, folder, parent, contexts } = await placement(vault, values, title);
const { ticket_type, actorTag } = requireFlags(kind, values);
const path = `${folder}/${title}.md`;
refuseCollisions(vault, path, title);
const blockers = await resolveBlockers(vault, values['blocked-by']);
const status = vault.requireStatus(values.status ?? STATUS.open);
const tags = [TASK_TAG];
if (actorTag) tags.push(actorTag);
for (const tag of values.tag ?? []) if (!tags.includes(tag)) tags.push(tag);
const now = localIso();
const frontmatter = { status, priority: vault.taskType.fields?.priority?.default, contexts, projects: [parent], date_created: now, date_modified: now };
if (frontmatter.priority === undefined) delete frontmatter.priority;
if (blockers.length) frontmatter.blockedBy = blockers;
frontmatter.tags = tags;
if (ticket_type) frontmatter.ticket_type = ticket_type;
const extra = values.set?.length ? await setFields(vault, { path, title, frontmatter: {}, body: '' }, values.set) : { fields: {}, warnings: [] };
for (const key of Object.keys(extra.fields)) {
if (FLAG_KEYS.includes(key)) throw usage(`--set ${key}: use the flag for it (or the lifecycle verbs) instead`);
if (extra.fields[key] === null) throw usage(`--set ${key}=: nothing to remove on a new note`);
frontmatter[key] = extra.fields[key];
}
const result = await vault.collection.create({ path, frontmatter, body: ensureSections(body ?? '') });
if (result.error) throw failure(`could not create ${path}: ${result.error.message}`, result.error.code);
vault.noteCreated(path);
return { note: await vault.readNote(path), warnings: extra.warnings };
}
export { join, titleOf };
@@ -0,0 +1,22 @@
// Errors the CLI reports on stderr with a specific exit code. Anything that is
// not a TicketError is a bug and surfaces as a stack trace.
export const EXIT = {
ERROR: 1, // usage mistakes and failures
REFUSED: 2, // the request was understood and declined: collision, blocked, ambiguous, not found, open children
CONFLICT: 3, // the file changed between read and write
};
export class TicketError extends Error {
constructor(message, { exitCode = EXIT.ERROR, code = 'error' } = {}) {
super(message);
this.name = 'TicketError';
this.exitCode = exitCode;
this.code = code;
}
}
export const usage = (message) => new TicketError(message, { exitCode: EXIT.ERROR, code: 'usage' });
export const failure = (message, code = 'error') => new TicketError(message, { exitCode: EXIT.ERROR, code });
export const refusal = (message, code = 'refused') => new TicketError(message, { exitCode: EXIT.REFUSED, code });
export const conflict = (message) => new TicketError(message, { exitCode: EXIT.CONFLICT, code: 'concurrent_modification' });
@@ -0,0 +1,154 @@
// The transition table as code: what claim, stall, unstall, close, and set
// do to a note's frontmatter. Each returns { fields, body?, warnings } for
// Vault.write(); nothing here touches the disk.
import { ACTOR_TAGS, RELTYPE, STALL_TAG, STATUS, TASK_TAG, isEffortPath, linkTarget, wikilink } from './vault.mjs';
import { appendNote } from './body.mjs';
import { failure, refusal, usage } from './errors.mjs';
import { localDate } from './time.mjs';
const LIST_FIELDS = ['tags', 'contexts', 'projects', 'blockedBy'];
const ACTOR_WORDS = Object.fromEntries(Object.entries(ACTOR_TAGS).map(([tag, word]) => [word, tag]));
const tagsOf = (note) => (Array.isArray(note.frontmatter.tags) ? [...note.frontmatter.tags] : []);
const without = (list, ...drop) => list.filter((t) => !drop.includes(t));
/** Claim: In Progress under `session`, unless someone else holds it or it is blocked. */
export async function claim(vault, note, { session, force }) {
const { status, agent_session: holder } = note.frontmatter;
if (!force && status === STATUS.inProgress && holder && holder !== session) {
throw refusal(`${note.title} is already In Progress under session ${holder}; pass --force to take it over`, 'claimed');
}
const blockers = await vault.openBlockers(note);
if (!force && blockers.length) {
throw refusal(`${note.title} is blocked by ${blockers.map(wikilink).join(', ')}; pass --force to claim it anyway`, 'blocked');
}
return { fields: { status: vault.requireStatus(STATUS.inProgress), agent_session: session }, warnings: [] };
}
/** Stall: add needs-info and log the question, in one write. */
export function stall(note, text) {
const tags = tagsOf(note);
if (!tags.includes(STALL_TAG)) tags.push(STALL_TAG);
const { body, warning } = appendNote(note.body, text, localDate());
return { fields: { tags }, body, warnings: warning ? [warning] : [] };
}
/** Unstall: drop needs-info, optionally logging the answer. */
export function unstall(note, text) {
const result = { fields: { tags: without(tagsOf(note), STALL_TAG) }, warnings: [] };
if (text !== undefined) {
const { body, warning } = appendNote(note.body, text, localDate());
result.body = body;
if (warning) result.warnings.push(warning);
}
return result;
}
/**
* Close: Done (or Wont Do), completion date, needs-info cleared, actor tag
* corrected when --actor says the other actor did the work. An effort with
* open children is refused: close or abandon them first.
*/
export async function close(vault, note, { wontDo, actor }) {
const effort = isEffortPath(note.path);
if (actor !== undefined) {
if (effort) throw usage(`${note.title} is an effort; efforts carry no actor tag`);
if (!ACTOR_WORDS[actor]) throw usage(`--actor must be one of: ${Object.keys(ACTOR_WORDS).join(', ')}`);
}
if (effort) {
const open = (await vault.childrenOf(note.title)).filter((child) => !vault.isCompleted(child.status));
if (open.length) {
throw refusal(`${note.title} still has ${open.length} open ticket${open.length === 1 ? '' : 's'}; close them (or close them --wont-do) first:\n ${open.map((c) => `${c.status} ${c.title}`).join('\n ')}`, 'open_children');
}
}
let tags = without(tagsOf(note), STALL_TAG);
if (actor !== undefined) tags = [...without(tags, ...Object.keys(ACTOR_TAGS)), ACTOR_WORDS[actor]];
const status = vault.requireStatus(wontDo ? STATUS.wontDo : STATUS.done);
return { fields: { status, tags, date_completed: localDate() }, warnings: [] };
}
const ASSIGNMENT = /^([A-Za-z_][\w]*)(\+=|-=|=)([\s\S]*)$/;
/**
* Set: `k=v` assigns (typed by the vault's schema), `k=` removes, `k+=v` and
* `k-=v` edit the list fields. Keys the verbs own are allowed but warned
* about, so the escape hatch stays visible.
*/
export async function set(vault, note, assignments) {
if (assignments.length === 0) throw usage('set needs at least one k=v, k=, k+=v, or k-=v');
const fields = {};
const warnings = [];
const current = (key) => (key in fields ? fields[key] : note.frontmatter[key]);
for (const assignment of assignments) {
const match = assignment.match(ASSIGNMENT);
if (!match) throw usage(`not an assignment: "${assignment}"`);
const [, key, op, raw] = match;
const field = vault.taskType.fields?.[key];
if (!field) warnings.push(`"${key}" is not a field of the vault's task type; TaskNotes may not preserve it`);
if (key === 'status') warnings.push('status set directly — claim and close maintain the rest of the transition (session, completion date, needs-info)');
if (op === '=') {
fields[key] = raw === '' ? null : coerce(vault, key, field, raw);
continue;
}
if (!LIST_FIELDS.includes(key)) throw usage(`${op} works on ${LIST_FIELDS.join(', ')} only, not "${key}"`);
const list = Array.isArray(current(key)) ? [...current(key)] : [];
if (key === 'tags') {
if (raw === TASK_TAG && op === '-=') throw refusal(`removing the "${TASK_TAG}" tag would turn ${note.title} into a plain note`, 'not_a_task');
if (raw === STALL_TAG) warnings.push(`${STALL_TAG} set directly — stall and unstall also log the question or answer`);
if (raw in ACTOR_TAGS) warnings.push(`${raw} set directly — close --actor corrects the actor at completion`);
}
fields[key] = op === '+=' ? await addTo(vault, key, list, raw) : removeFrom(key, list, raw);
if (fields[key].length === 0 && key !== 'tags') fields[key] = null;
}
return { fields, warnings };
}
async function addTo(vault, key, list, raw) {
if (key === 'blockedBy') {
const path = vault.resolve(raw); // a blocker that does not resolve never blocks, so it is refused here
const target = await vault.readNote(path);
if (!vault.isTask(target)) throw refusal(`${target.path} is not a task; a blocker must be one`, 'not_a_task');
if (list.some((b) => sameTitle(b?.uid ?? b, target.title))) return list;
return [...list, { uid: wikilink(target.title), reltype: RELTYPE }];
}
const value = key === 'projects' ? wikilink(raw) : raw;
return list.includes(value) ? list : [...list, value];
}
function removeFrom(key, list, raw) {
if (key === 'blockedBy') return list.filter((b) => !sameTitle(b?.uid ?? b, raw));
if (key === 'projects') return list.filter((p) => !sameTitle(p, raw));
return list.filter((v) => v !== raw);
}
const sameTitle = (a, b) => linkTarget(a).toLowerCase() === linkTarget(b).toLowerCase();
/** Turn the CLI's string into what the schema says the field holds. */
function coerce(vault, key, field, raw) {
switch (field?.type) {
case 'integer': {
const n = Number(raw);
if (!Number.isInteger(n)) throw usage(`${key} must be an integer, got "${raw}"`);
return n;
}
case 'number': {
const n = Number(raw);
if (Number.isNaN(n)) throw usage(`${key} must be a number, got "${raw}"`);
return n;
}
case 'boolean':
if (raw !== 'true' && raw !== 'false') throw usage(`${key} must be true or false, got "${raw}"`);
return raw === 'true';
case 'enum':
if (Array.isArray(field.values) && !field.values.includes(raw)) throw usage(`${key} must be one of: ${field.values.join(', ')} (got "${raw}")`);
return raw;
case 'list':
return [key === 'projects' ? wikilink(raw) : key === 'blockedBy' ? { uid: wikilink(raw), reltype: RELTYPE } : raw];
case 'link':
return wikilink(raw);
default:
return raw;
}
}
export { failure };
+21
View File
@@ -0,0 +1,21 @@
// Timestamps in the shape TaskNotes writes: local time with its UTC offset,
// millisecond precision. mdbase 0.2.2 does not honour the type's `generated:`
// annotations, so the tool stamps these itself.
const pad = (n, width = 2) => String(n).padStart(width, '0');
export function localIso(date = new Date()) {
const offset = -date.getTimezoneOffset();
const sign = offset >= 0 ? '+' : '-';
const abs = Math.abs(offset);
return (
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
`T${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}.${pad(date.getMilliseconds(), 3)}` +
`${sign}${pad(Math.floor(abs / 60))}:${pad(abs % 60)}`
);
}
export function localDate(date = new Date()) {
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
}
export const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
+317
View File
@@ -0,0 +1,317 @@
// The vault as `ticket` sees it: an mdbase collection whose `task` type is
// maintained by TaskNotes. Everything schema-shaped (statuses, which of them
// count as completed) is read from that type, never hardcoded here. This
// module owns addressing, reading, and the derived `isBlocked`; the CLI owns
// only flags and output.
import { existsSync, readdirSync, statSync } from 'node:fs';
import { basename, join } from 'node:path';
import { Collection, getTypeAsync, loadConfigAsync } from '@callumalpass/mdbase';
import { conflict, failure, refusal } from './errors.mjs';
import { localIso } from './time.mjs';
/** Where dev tickets live: `TaskNotes/Dev/<repo>/<effort>/`. */
export const DEV_ROOT = 'TaskNotes/Dev';
export const TASK_TAG = 'task';
export const STALL_TAG = 'needs-info';
/** The statuses the model's transition table names. Validated against the vault's type on use. */
export const STATUS = { open: 'Open', inProgress: 'In Progress', done: 'Done', wontDo: 'Wont Do' };
export const RELTYPE = 'FINISHTOSTART';
/** Actor tag → the word the CLI uses for it. */
export const ACTOR_TAGS = { 'agent-step': 'agent', 'human-step': 'human' };
/** The title of a note is its filename stem. It is never a frontmatter key. */
export function titleOf(path) {
return basename(path).replace(/\.md$/, '');
}
/** `[[path/Title|alias]]` → `Title`; bare titles and paths pass through minus any `.md`. */
export function linkTarget(value) {
let s = String(value).trim();
if (s.startsWith('[[') && s.endsWith(']]')) s = s.slice(2, -2);
s = s.split('|')[0].split('#')[0].trim();
return s.replace(/\.md$/, '');
}
/** `[[Title]]` for a bare or already-bracketed title. */
export function wikilink(title) {
return `[[${linkTarget(title)}]]`;
}
/** An effort sits at `TaskNotes/Dev/<repo>/<title>/<title>.md`; everything else in there is a ticket. */
export function isEffortPath(path) {
const parts = path.split('/');
return parts.length === 5 && `${parts[0]}/${parts[1]}` === DEV_ROOT && titleOf(path) === parts[3];
}
export function actorOf(frontmatter) {
const tags = Array.isArray(frontmatter.tags) ? frontmatter.tags : [];
for (const [tag, word] of Object.entries(ACTOR_TAGS)) if (tags.includes(tag)) return word;
return null;
}
export function hasTag(frontmatter, tag) {
return Array.isArray(frontmatter.tags) && frontmatter.tags.includes(tag);
}
export class Vault {
/**
* Open the collection at `root`. Refuses anything that is not an mdbase
* collection with a `task` type rather than inventing a schema.
*/
static async open(root, { warn = () => {} } = {}) {
const config = await loadConfigAsync(root, { allowFutureMinor: true });
if (!config.valid || !config.config) {
throw failure(`${root} is not an mdbase collection (${config.error?.message ?? 'cannot load mdbase.yaml'})`, 'no_collection');
}
const type = await getTypeAsync(root, config.config, 'task');
if (!type.valid || !type.type) {
throw failure(`${root} has no "task" type under ${config.config.settings.types_folder}; enable TaskNotes' mdbase export`, 'no_task_type');
}
const opened = await Collection.open(root);
if (opened.error || !opened.collection) {
throw failure(`${root}: ${opened.error?.message ?? 'cannot open collection'}`, 'open_failed');
}
return new Vault(root, opened.collection, config.config, type.type, warn);
}
constructor(root, collection, config, taskType, warn) {
this.root = root;
this.collection = collection;
this.config = config;
this.taskType = taskType;
this.warn = warn;
this.completedStatuses = taskType.fields?.status?.tn_completed_values ?? [];
this.noteCache = new Map();
}
isCompleted(status) {
return this.completedStatuses.includes(status);
}
async close() {
await this.collection.close();
}
// ---- addressing -------------------------------------------------------
/** Every markdown note, vault-relative, skipping dot-folders and the types folder. */
files() {
if (this.fileSet) return this.fileSet;
const typesFolder = this.config.settings.types_folder;
const found = new Set();
const walk = (dir) => {
for (const entry of readdirSync(join(this.root, dir), { withFileTypes: true })) {
if (entry.name.startsWith('.')) continue;
const rel = dir ? `${dir}/${entry.name}` : entry.name;
if (entry.isDirectory()) {
if (rel !== typesFolder) walk(rel);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
found.add(rel);
}
}
};
walk('');
this.fileSet = found;
return found;
}
/** Lowercased title → paths. This is how wikilinks bind: case-insensitive, vault-wide. */
byTitle() {
if (this.titleIndex) return this.titleIndex;
const index = new Map();
for (const path of this.files()) {
const key = titleOf(path).toLowerCase();
index.set(key, [...(index.get(key) ?? []), path]);
}
this.titleIndex = index;
return index;
}
/** Paths whose title matches `title` case-insensitively. */
pathsTitled(title) {
return this.byTitle().get(title.toLowerCase()) ?? [];
}
/**
* Resolve a reference — a vault-relative path, or a title, bare or
* `[[wikilinked]]` — to the one note it names. Ambiguity and absence are
* refusals, not guesses.
*/
resolve(ref) {
const target = linkTarget(ref);
const asPath = `${target}.md`;
if (this.files().has(asPath)) return asPath;
const title = titleOf(asPath);
const candidates = this.pathsTitled(title);
if (candidates.length === 1) return candidates[0];
if (candidates.length === 0) throw refusal(`no note titled "${title}"`, 'not_found');
throw refusal(`"${title}" is ambiguous — it names ${candidates.length} notes:\n ${candidates.join('\n ')}`, 'ambiguous');
}
/** The title of the note `ref` names, after resolution. */
titleOf(ref) {
return titleOf(this.resolve(ref));
}
// ---- reading ----------------------------------------------------------
/** A note as it is on disk: raw frontmatter, body, and the mtime the write guard compares against. */
async readNote(path) {
if (this.noteCache.has(path)) return this.noteCache.get(path);
const result = await this.collection.read(path);
if (result.error) throw failure(`${path}: ${result.error.message}`, result.error.code);
const note = {
path,
title: titleOf(path),
frontmatter: result.rawFrontmatter ?? {},
body: result.body ?? '',
mtime: statSync(join(this.root, path)).mtimeMs,
};
this.noteCache.set(path, note);
return note;
}
/** Register a note the tool just created so addressing and collision checks see it. */
noteCreated(path) {
this.files().add(path);
this.titleIndex = undefined;
}
/** Forget a cached read, after a write. */
forget(path) {
this.noteCache.delete(path);
}
isTask(note) {
return hasTag(note.frontmatter, TASK_TAG);
}
/**
* Titles of the blockers that currently block `note`: those resolving to a
* task whose status is not completed. A blocker that does not resolve to
* exactly one task warns and does not block — TaskNotes ignores
* unresolvable links the same way.
*/
async openBlockers(note) {
const blockers = Array.isArray(note.frontmatter.blockedBy) ? note.frontmatter.blockedBy : [];
const open = [];
for (const blocker of blockers) {
const uid = typeof blocker === 'string' ? blocker : blocker?.uid;
if (!uid) continue;
const title = titleOf(`${linkTarget(uid)}.md`);
const candidates = this.pathsTitled(title);
if (candidates.length !== 1) {
this.warn(`${note.title}: blocker ${uid} ${candidates.length ? 'is ambiguous' : 'does not resolve'}; ignoring it`);
continue;
}
const target = await this.readNote(candidates[0]);
if (!this.isTask(target) || target.frontmatter.status === undefined) {
this.warn(`${note.title}: blocker ${uid} is not a task; ignoring it`);
continue;
}
if (!this.isCompleted(target.frontmatter.status)) open.push(target.title);
}
return open;
}
async isBlocked(note) {
return (await this.openBlockers(note)).length > 0;
}
/** The vault's status values must include `status`; the model's names are not invented. */
requireStatus(status) {
const values = this.taskType.fields?.status?.values ?? [];
if (!values.includes(status)) throw failure(`the vault's task type has no "${status}" status (it has: ${values.join(', ')})`, 'unknown_status');
return status;
}
/** Children of the effort titled `title`: every task whose projects contain its wikilink. */
async childrenOf(title) {
return this.records({ where: childOfWhere(title), folder: DEV_ROOT });
}
/** The record: path, title, derived isBlocked, then the frontmatter as it is on disk. */
async record(note, { body = false } = {}) {
const record = { path: note.path, title: note.title, isBlocked: await this.isBlocked(note), ...note.frontmatter };
if (body) record.body = note.body;
return record;
}
/**
* Records for every task matching `where` (mdbase's expression language)
* under `folder`, sorted by path.
*/
async records({ where, folder } = {}) {
const query = await this.collection.query({ types: ['task'], where: where || undefined, folder: folder || undefined });
if (query.error) throw failure(`query failed: ${query.error.message}`, query.error.code);
const rows = (query.results ?? []).sort((a, b) => a.path.localeCompare(b.path));
const records = [];
for (const row of rows) records.push(await this.record(await this.readNote(row.path)));
return records;
}
// ---- writing ----------------------------------------------------------
/**
* Rewrite a note's frontmatter fields (null removes a key) and/or body,
* bumping `date_modified`. `note` must come from readNote(): the mtime
* recorded then is what the guard compares against, so a file that changed
* in between is refused untouched. mdbase's own check covers only its
* internal stat→write window; its pre-write hook is where ours runs.
*/
async write(note, { fields = {}, body } = {}) {
const full = join(this.root, note.path);
await testHold();
this.collection.preWriteHook = (path) => {
if (path === note.path && statSync(full).mtimeMs !== note.mtime) {
throw conflict(`${note.path} changed since it was read; nothing was written — re-run the command`);
}
};
let result;
try {
result = await this.collection.update({ path: note.path, fields: { ...fields, date_modified: localIso() }, body });
} finally {
this.collection.preWriteHook = undefined;
}
if (result.error) {
if (result.error.code === 'concurrent_modification') throw conflict(`${note.path} changed while it was being written; re-run the command`);
throw failure(`${note.path}: ${result.error.message}`, result.error.code);
}
this.forget(note.path);
return this.readNote(note.path);
}
/** An expression fragment matching tasks whose status is not a completed value. */
openWhere() {
if (this.completedStatuses.length === 0) return undefined;
return `!(${this.completedStatuses.map((s) => `status == ${JSON.stringify(s)}`).join(' || ')})`;
}
}
/** AND expression fragments together; empty fragments are skipped. */
export function andWhere(...parts) {
const kept = parts.filter(Boolean);
return kept.length ? kept.map((p) => `(${p})`).join(' && ') : undefined;
}
/** `projects.contains("[[Title]]")` — how effort membership is queried. */
export function childOfWhere(title) {
return `projects.contains(${JSON.stringify(`[[${title}]]`)})`;
}
/**
* Test-only: when TICKET_TEST_HOLD names a file, announce the hold by creating
* `<file>.waiting`, then block until `<file>` exists. Lets a test change a note
* between the tool's read and its write.
*/
async function testHold() {
const hold = process.env.TICKET_TEST_HOLD;
if (!hold) return;
const { writeFileSync } = await import('node:fs');
writeFileSync(`${hold}.waiting`, '');
const deadline = Date.now() + 10_000;
while (!existsSync(hold)) {
if (Date.now() > deadline) throw failure('TICKET_TEST_HOLD: timed out waiting for the release file');
await new Promise((r) => setTimeout(r, 10));
}
}
@@ -0,0 +1,121 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { makeVault, makeHome, readNote, ticket, ticketAsync, waitForFile, writeNote } from './helpers.mjs';
const REL = 'TaskNotes/Dev/repo/Effort/Ticket.md';
const TASK = 'status: Open\nprojects:\n - "[[Effort]]"\ndate_created: 2026-08-01T10:00:00.000-07:00\ndate_modified: 2026-08-01T10:00:00.000-07:00\ntags:\n - task\n - agent-step';
const BODY = '## What to build\n\n- [ ] thing\n\n## Notes\n\n## As Built\n';
const TODAY = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' });
const ISO_LOCAL = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/;
function frontmatterOf(text) {
return text.split('---')[1];
}
function bodyOf(text) {
return text.split('---').slice(2).join('---').replace(/^\n/, '');
}
test('note: first entry of the day gets a dated heading above ## As Built; the second joins it', () => {
const vault = makeVault();
writeNote(vault, REL, TASK, BODY);
const first = ticket(['note', 'Ticket', 'First finding.'], { vault });
assert.equal(first.code, 0, first.err);
assert.equal(bodyOf(readNote(vault, REL)), `## What to build\n\n- [ ] thing\n\n## Notes\n\n### [[${TODAY}]]\n\nFirst finding.\n\n## As Built\n`);
const second = ticket(['note', 'Ticket', 'Second finding.'], { vault });
assert.equal(second.code, 0, second.err);
assert.equal(bodyOf(readNote(vault, REL)), `## What to build\n\n- [ ] thing\n\n## Notes\n\n### [[${TODAY}]]\n\nFirst finding.\n\nSecond finding.\n\n## As Built\n`);
});
test('note: a different --date starts a new heading, and only the last heading is reused', () => {
const vault = makeVault();
writeNote(vault, REL, TASK, BODY);
ticket(['note', 'Ticket', 'Old.', '--date', '2026-01-01'], { vault });
ticket(['note', 'Ticket', 'New.'], { vault });
ticket(['note', 'Ticket', 'Older still.', '--date', '2026-01-01'], { vault });
assert.equal(
bodyOf(readNote(vault, REL)),
`## What to build\n\n- [ ] thing\n\n## Notes\n\n### [[2026-01-01]]\n\nOld.\n\n### [[${TODAY}]]\n\nNew.\n\n### [[2026-01-01]]\n\nOlder still.\n\n## As Built\n`,
);
assert.equal(ticket(['note', 'Ticket', 'x', '--date', 'yesterday'], { vault }).code, 1);
});
test('note: missing sections are added with a warning rather than misfiling the entry', () => {
const vault = makeVault();
writeNote(vault, REL, TASK, '## What to build\n\n- [ ] thing\n');
const noAsBuilt = ticket(['note', 'Ticket', 'Entry.'], { vault });
assert.equal(noAsBuilt.code, 0, noAsBuilt.err);
assert.match(noAsBuilt.err, /warning: .*no "## As Built" section/);
assert.equal(bodyOf(readNote(vault, REL)), `## What to build\n\n- [ ] thing\n\n## Notes\n\n### [[${TODAY}]]\n\nEntry.\n`);
writeNote(vault, REL, TASK, '## What to build\n\n## As Built\n\nDeviated.\n');
const noNotes = ticket(['note', 'Ticket', 'Entry.'], { vault });
assert.match(noNotes.err, /warning: .*no "## Notes" section/);
assert.equal(bodyOf(readNote(vault, REL)), `## What to build\n\n## Notes\n\n### [[${TODAY}]]\n\nEntry.\n\n## As Built\n\nDeviated.\n`);
});
test('as-built: appends under ## As Built, keeping anything already there', () => {
const vault = makeVault();
writeNote(vault, REL, TASK, BODY);
ticket(['as-built', 'Ticket', 'Used a FIFO instead of a socket.'], { vault });
ticket(['as-built', 'Ticket', 'And no retries.'], { vault });
assert.equal(bodyOf(readNote(vault, REL)), `## What to build\n\n- [ ] thing\n\n## Notes\n\n## As Built\n\nUsed a FIFO instead of a socket.\n\nAnd no retries.\n`);
writeNote(vault, REL, TASK, '## What to build\n');
const added = ticket(['as-built', 'Ticket', 'Late.'], { vault });
assert.match(added.err, /warning: .*no "## As Built" section/);
assert.equal(bodyOf(readNote(vault, REL)), '## What to build\n\n## As Built\n\nLate.\n');
});
test('note: text comes from the argument or --file (- is stdin), never both, never empty', () => {
const vault = makeVault();
writeNote(vault, REL, TASK, BODY);
const file = join(makeHome(), 'entry.md');
writeFileSync(file, 'From a file,\nwith two lines.\n');
assert.equal(ticket(['note', 'Ticket', '--file', file], { vault }).code, 0);
assert.equal(ticket(['note', 'Ticket', '--file', '-'], { vault, input: 'From stdin.\n' }).code, 0);
assert.match(bodyOf(readNote(vault, REL)), /From a file,\nwith two lines\.\n\nFrom stdin\.\n\n## As Built/);
assert.equal(ticket(['note', 'Ticket', 'arg', '--file', file], { vault }).code, 1);
assert.equal(ticket(['note', 'Ticket'], { vault }).code, 1);
assert.equal(ticket(['note', 'Ticket', ' '], { vault }).code, 1);
});
test('every write bumps date_modified in local-offset shape, leaves date_created alone, and prints the record', () => {
const vault = makeVault();
writeNote(vault, REL, TASK, BODY);
const result = ticket(['note', '--json', 'Ticket', 'Entry.'], { vault });
assert.equal(result.code, 0, result.err);
const record = result.json()[0];
assert.equal(record.path, REL);
assert.match(record.date_modified, ISO_LOCAL);
assert.notEqual(record.date_modified, '2026-08-01T10:00:00.000-07:00');
assert.equal(String(record.date_created), '2026-08-01T10:00:00.000-07:00');
assert.match(frontmatterOf(readNote(vault, REL)), /date_created: '?2026-08-01T10:00:00\.000-07:00'?\n/);
const text = ticket(['note', 'Ticket', 'Entry.'], { vault });
assert.match(text.out, /^Open\s+agent\s+Ticket\s+TaskNotes\/Dev\/repo\/Effort\/Ticket\.md\n$/);
});
test('mutations refuse a note without the task tag and leave it untouched', () => {
const vault = makeVault();
writeNote(vault, 'Plain.md', 'kind: plain', 'body\n');
const before = readNote(vault, 'Plain.md');
const result = ticket(['note', 'Plain', 'Entry.'], { vault });
assert.equal(result.code, 2);
assert.match(result.err, /not a task/);
assert.equal(readNote(vault, 'Plain.md'), before);
});
test('a note that changes between read and write is refused with exit 3 and left as the other writer left it', async () => {
const vault = makeVault();
writeNote(vault, REL, TASK, BODY);
const hold = join(makeHome(), 'go');
const run = ticketAsync(['note', 'Ticket', 'Mine.'], { vault, env: { TICKET_TEST_HOLD: hold } });
await waitForFile(`${hold}.waiting`);
const theirs = writeNote(vault, REL, `${TASK}\nagent_session: someone-else`, `${BODY}\nTheirs.\n`);
const theirsText = readNote(vault, REL);
writeFileSync(hold, '');
const result = await run;
assert.equal(result.code, 3, result.err);
assert.match(result.err, /changed since it was read/);
assert.equal(readNote(vault, REL), theirsText, `${theirs} must be untouched`);
});
@@ -0,0 +1,138 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync } from 'node:fs';
import { join } from 'node:path';
import { makeVault, readNote, ticket, writeNote } from './helpers.mjs';
const ISO_LOCAL = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}[+-]\d{2}:\d{2}$/;
const keysOf = (text) => text.split('---')[1].split('\n').filter((l) => /^[A-Za-z_]/.test(l)).map((l) => l.split(':')[0]);
const bodyOf = (text) => text.split('---').slice(2).join('---').replace(/^\n/, '');
function effortArgs(extra = []) {
return ['create', '--repo', 'repo', '--title', 'New effort', '--context', 'Coding', ...extra];
}
test('create --repo: an effort at TaskNotes/Dev/<repo>/<title>/<title>.md, native frontmatter, sections appended', () => {
const vault = makeVault();
const result = ticket(effortArgs(['--json']), { vault });
assert.equal(result.code, 0, result.err);
const rel = 'TaskNotes/Dev/repo/New effort/New effort.md';
const record = result.json()[0];
assert.equal(record.path, rel);
assert.equal(record.title, 'New effort');
assert.equal(record.isBlocked, false);
const text = readNote(vault, rel);
assert.deepEqual(keysOf(text), ['status', 'priority', 'contexts', 'projects', 'date_created', 'date_modified', 'tags']);
assert.equal(record.status, 'Open');
assert.equal(record.priority, '2-Normal');
assert.deepEqual(record.contexts, ['Coding']);
assert.deepEqual(record.projects, ['[[repo]]']);
assert.deepEqual(record.tags, ['task']);
assert.match(String(record.date_created), ISO_LOCAL);
assert.equal(record.date_created, record.date_modified);
assert.equal(record.date_scheduled, undefined);
assert.equal(record.title_key, undefined);
assert.doesNotMatch(text, /^title:/m);
assert.equal(bodyOf(text), '## Notes\n\n## As Built\n');
});
test('create --repo: flag validation, and the repo directory spelling must match an existing one', () => {
const vault = makeVault();
assert.equal(ticket(['create', '--repo', 'repo', '--title', 'E'], { vault }).code, 1, '--context required');
assert.equal(ticket(effortArgs(['--type', 'implementation']), { vault }).code, 1);
assert.equal(ticket(effortArgs(['--actor', 'agent']), { vault }).code, 1);
assert.equal(ticket(effortArgs(['--blocked-by', 'x']), { vault }).code, 1);
assert.equal(ticket(['create', '--title', 'E', '--context', 'C'], { vault }).code, 1, 'one of --repo/--effort');
assert.equal(ticket(['create', '--repo', 'r', '--effort', 'e', '--title', 'E'], { vault }).code, 1);
assert.equal(ticket(['create', '--repo', 'repo', '--title', 'a/b', '--context', 'C'], { vault }).code, 1);
assert.equal(ticket(['create', '--repo', 'repo', '--title', 'E.md', '--context', 'C'], { vault }).code, 1);
assert.equal(ticket(['create', '--repo', 'repo', '--context', 'C'], { vault }).code, 1, '--title required');
assert.equal(ticket(effortArgs(), { vault }).code, 0);
const wrongCase = ticket(['create', '--repo', 'Repo', '--title', 'Other', '--context', 'C'], { vault });
assert.equal(wrongCase.code, 2);
assert.match(wrongCase.err, /"repo" already exists/);
assert.equal(existsSync(join(vault, 'TaskNotes/Dev/Repo')), false);
assert.equal(ticket(['create', '--repo', '[[repo]]', '--title', 'Other', '--context', 'C'], { vault }).code, 0, 'a bracketed repo is fine');
});
test('create --effort: a ticket beside the effort, parent and contexts derived, body from stdin, sections appended', () => {
const vault = makeVault();
ticket(effortArgs(), { vault });
const result = ticket(['create', '--effort', 'New effort', '--title', 'First slice', '--type', 'implementation', '--actor', 'agent', '--body-file', '-', '--json'], { vault, input: '## What to build\n\n- [ ] thing\n' });
assert.equal(result.code, 0, result.err);
const rel = 'TaskNotes/Dev/repo/New effort/First slice.md';
const record = result.json()[0];
assert.equal(record.path, rel);
assert.deepEqual(record.projects, ['[[New effort]]']);
assert.deepEqual(record.contexts, ['Coding'], 'inherited from the effort');
assert.deepEqual(record.tags, ['task', 'agent-step']);
assert.equal(record.ticket_type, 'implementation');
const text = readNote(vault, rel);
assert.deepEqual(keysOf(text), ['status', 'priority', 'contexts', 'projects', 'date_created', 'date_modified', 'tags', 'ticket_type']);
assert.equal(bodyOf(text), '## What to build\n\n- [ ] thing\n\n## Notes\n\n## As Built\n');
const human = ticket(['create', '--effort', '[[new effort]]', '--title', 'Ask the user', '--type', 'grilling', '--actor', 'human'], { vault });
assert.equal(human.code, 0, human.err);
assert.match(human.out, /^Open\s+human\s+Ask the user\s+TaskNotes\/Dev\/repo\/New effort\/Ask the user\.md\n$/);
});
test('create --effort: flag validation; the effort must exist and be an effort', () => {
const vault = makeVault();
ticket(effortArgs(), { vault });
const base = ['create', '--effort', 'New effort', '--title', 'T'];
assert.equal(ticket([...base, '--actor', 'agent'], { vault }).code, 1, '--type required');
assert.equal(ticket([...base, '--type', 'implementation'], { vault }).code, 1, '--actor required');
assert.equal(ticket([...base, '--type', 'bogus', '--actor', 'agent'], { vault }).code, 1);
assert.equal(ticket([...base, '--type', 'implementation', '--actor', 'robot'], { vault }).code, 1);
assert.equal(ticket([...base, '--type', 'implementation', '--actor', 'agent', '--context', 'X'], { vault }).code, 1, 'contexts are inherited');
assert.equal(ticket(['create', '--effort', 'Nope', '--title', 'T', '--type', 'implementation', '--actor', 'agent'], { vault }).code, 2);
assert.equal(ticket([...base, '--type', 'implementation', '--actor', 'agent'], { vault }).code, 0);
const underTicket = ticket(['create', '--effort', 'T', '--title', 'U', '--type', 'implementation', '--actor', 'agent'], { vault });
assert.equal(underTicket.code, 2);
assert.match(underTicket.err, /not an effort/);
});
test('create: blockers must resolve to tasks, are deduplicated, and nothing is written on refusal', () => {
const vault = makeVault();
ticket(effortArgs(), { vault });
const mk = (title, extra) => ticket(['create', '--effort', 'New effort', '--title', title, '--type', 'implementation', '--actor', 'agent', ...extra], { vault });
assert.equal(mk('A', []).code, 0);
const dangling = mk('B', ['--blocked-by', 'Nope']);
assert.equal(dangling.code, 2);
assert.match(dangling.err, /no note titled "Nope"/);
assert.equal(existsSync(join(vault, 'TaskNotes/Dev/repo/New effort/B.md')), false);
writeNote(vault, 'Plain.md', 'kind: plain', 'x\n');
assert.equal(mk('B', ['--blocked-by', 'Plain']).code, 2, 'a blocker must be a task');
const ok = mk('B', ['--blocked-by', 'a', '--blocked-by', '[[A]]', '--json']);
assert.equal(ok.code, 0, ok.err);
assert.deepEqual(ok.json()[0].blockedBy, [{ uid: '[[A]]', reltype: 'FINISHTOSTART' }]);
assert.equal(ok.json()[0].isBlocked, true);
assert.deepEqual(keysOf(readNote(vault, 'TaskNotes/Dev/repo/New effort/B.md')), ['status', 'priority', 'contexts', 'projects', 'date_created', 'date_modified', 'blockedBy', 'tags', 'ticket_type']);
});
test('create: refuses an existing path and any case-insensitive title twin anywhere in the vault', () => {
const vault = makeVault();
assert.equal(ticket(effortArgs(), { vault }).code, 0);
const again = ticket(effortArgs(), { vault });
assert.equal(again.code, 2);
assert.match(again.err, /already exists/);
writeNote(vault, 'Journal/random note.md', 'kind: plain', 'x\n');
const twin = ticket(['create', '--repo', 'repo', '--title', 'Random Note', '--context', 'C'], { vault });
assert.equal(twin.code, 2);
assert.match(twin.err, /Journal\/random note\.md/);
assert.equal(existsSync(join(vault, 'TaskNotes/Dev/repo/Random Note')), false);
});
test('create: --status, --tag, and --set land in the frontmatter; --set may not touch what flags own', () => {
const vault = makeVault();
const result = ticket(effortArgs(['--status', 'In Progress', '--tag', 'extra', '--tag', 'task', '--set', 'git_branch=feat/x', '--set', 'timeEstimate=5', '--json']), { vault });
assert.equal(result.code, 0, result.err);
const record = result.json()[0];
assert.equal(record.status, 'In Progress');
assert.deepEqual(record.tags, ['task', 'extra']);
assert.equal(record.git_branch, 'feat/x');
assert.equal(record.timeEstimate, 5);
assert.equal(ticket(['create', '--repo', 'repo', '--title', 'X', '--context', 'C', '--set', 'status=Done'], { vault }).code, 1);
assert.equal(ticket(['create', '--repo', 'repo', '--title', 'X', '--context', 'C', '--set', 'tags+=a'], { vault }).code, 1);
assert.equal(ticket(['create', '--repo', 'repo', '--title', 'X', '--context', 'C', '--status', 'Bogus'], { vault }).code, 1);
assert.equal(ticket(['create', '--repo', 'repo', '--title', 'X', '--context', 'C', 'stray'], { vault }).code, 1);
});
@@ -0,0 +1,243 @@
---
name: task
description: A task managed by the TaskNotes plugin for Obsidian.
display_name_key: title
strict: false
path_pattern: "TaskNotes/Tasks/{title}.md"
match:
where:
tags:
contains: "task"
fields:
title:
type: string
required: true
description: "Short summary of the task."
tn_role: title
status:
type: enum
required: true
values: [Open, In Progress, Done, Wont Do]
tn_completed_values: [Done, Wont Do]
default: Open
tn_role: status
priority:
type: enum
values: [0-None, 1-Low, 2-Normal, 3-High, 4-Urgent]
default: 2-Normal
tn_role: priority
date_due:
type: date
tn_role: due
date_scheduled:
type: date
tn_role: scheduled
contexts:
type: list
tn_role: contexts
items:
type: string
projects:
type: list
description: "Wikilinks to related project notes."
tn_role: projects
items:
type: link
timeEstimate:
type: integer
min: 0
description: "Estimated time in minutes."
tn_role: timeEstimate
date_completed:
type: date
tn_role: completedDate
date_created:
type: datetime
required: true
generated: now
tn_role: dateCreated
date_modified:
type: datetime
generated: now_on_write
tn_role: dateModified
recurrence:
type: string
tn_role: recurrence
recurrence_anchor:
type: enum
values: [scheduled, completion]
default: scheduled
tn_role: recurrenceAnchor
occurrence_materialization:
type: enum
values: [manual, on_completion, rolling]
default: manual
description: "How occurrence task notes are materialized for a recurring parent task."
tn_role: occurrenceMaterialization
occurrence_next_trigger:
type: enum
values: [completion, completion_or_skip]
default: completion
description: "Which occurrence state changes should materialize the next occurrence."
tn_role: occurrenceNextTrigger
occurrence_template:
type: link
description: "Optional template note used when materializing occurrences."
tn_role: occurrenceTemplate
occurrence_past_horizon:
type: string
description: "ISO 8601 duration controlling rolling materialization before today."
tn_role: occurrencePastHorizon
occurrence_future_horizon:
type: string
description: "ISO 8601 duration controlling rolling materialization after today."
tn_role: occurrenceFutureHorizon
recurrence_parent:
type: link
description: "Parent recurring task for a materialized occurrence note."
tn_role: recurrenceParent
occurrence_date:
type: date
description: "Target recurrence date for a materialized occurrence note."
tn_role: occurrenceDate
tags:
type: list
tn_role: tags
items:
type: string
timeEntries:
type: list
tn_role: timeEntries
items:
type: object
fields:
startTime:
type: datetime
endTime:
type: datetime
description:
type: string
duration:
type: integer
reminders:
type: list
description: "Reminder objects with id, type, offset, etc."
tn_role: reminders
items:
type: object
fields:
id:
type: string
required: true
type:
type: enum
values: [absolute, relative]
description:
type: string
relatedTo:
type: enum
values: [due, scheduled]
description: "Field the reminder is relative to (e.g. 'due')."
offset:
type: string
description: "ISO 8601 duration offset (e.g. '-PT1H')."
absoluteTime:
type: datetime
blockedBy:
type: list
tn_role: blockedBy
items:
type: object
fields:
uid:
type: link
required: true
reltype:
type: string
gap:
type: string
complete_instances:
type: list
tn_role: completeInstances
items:
type: date
skipped_instances:
type: list
tn_role: skippedInstances
items:
type: date
icsEventId:
type: list
tn_role: icsEventId
items:
type: string
googleCalendarEventId:
type: string
tn_role: googleCalendarEventId
googleCalendarExceptionEventId:
type: string
tn_role: googleCalendarExceptionEventId
googleCalendarExceptionOriginalScheduled:
type: date
tn_role: googleCalendarExceptionOriginalScheduled
googleCalendarMovedOriginalDates:
type: list
tn_role: googleCalendarMovedOriginalDates
items:
type: date
progress_dates:
type: list
items:
type: string
ticket_type:
type: string
git_worktree:
type: string
git_branch:
type: string
agent_session:
type: string
x-tasknotes:
nlp:
triggers:
- property_id: "tags"
trigger: "#"
enabled: true
- property_id: "contexts"
trigger: "@"
enabled: true
- property_id: "projects"
trigger: "+"
enabled: true
- property_id: "status"
trigger: "*"
enabled: true
- property_id: "priority"
trigger: "!"
enabled: false
- property_id: "field_1785881829222"
trigger: "field_1785881829222:"
enabled: false
---
# Task
This type definition describes the data schema for tasks managed by
[TaskNotes](https://github.com/callumalpass/tasknotes), an Obsidian plugin
for note-based task management.
It conforms to [mdbase-spec](https://github.com/callumalpass/mdbase-spec) v0.2.0,
a specification for typed markdown collections.
TaskNotes also adds a non-standard `tn_role` field annotation on schema
fields. This maps each field to its TaskNotes semantic role so custom
frontmatter field names can still be interpreted consistently.
The status field also includes `tn_completed_values`, listing
which status values count as completed.
This file is automatically generated from TaskNotes settings and should not be
edited manually. Changes to TaskNotes settings (statuses, priorities, field
mappings, user fields) will cause this file to be regenerated.
@@ -0,0 +1,8 @@
spec_version: "0.2.0"
name: "TaskNotes"
description: "Task collection managed by TaskNotes for Obsidian"
settings:
types_folder: "_types"
default_strict: false
exclude:
- "_types"
@@ -0,0 +1,86 @@
// Tests drive the CLI only through its command surface, against a scratch
// collection built from the checked-in fixture. Nothing here imports src/.
import { spawn, spawnSync } from 'node:child_process';
import { cpSync, existsSync, mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const here = dirname(fileURLToPath(import.meta.url));
export const BIN = join(here, '..', 'bin', 'ticket.mjs');
/** A fresh collection: the vault's mdbase.yaml and _types/task.md, nothing else. */
export function makeVault() {
const root = mkdtempSync(join(tmpdir(), 'ticket-vault-'));
cpSync(join(here, 'fixture'), root, { recursive: true });
return root;
}
/** A fresh HOME so ~/.config/mdbase-tasknotes never leaks in from the real one. */
export function makeHome() {
return mkdtempSync(join(tmpdir(), 'ticket-home-'));
}
/**
* Run `ticket ARGS`. The environment is built from scratch so the caller's
* config and MDBASE_TASKNOTES_PATH cannot influence the run; pass `vault` to
* point at a collection through the environment variable.
*/
export function ticket(args, { vault, home = makeHome(), cwd = home, env = {}, input } = {}) {
const result = spawnSync(process.execPath, [BIN, ...args], {
cwd,
input,
encoding: 'utf8',
env: {
PATH: process.env.PATH,
HOME: home,
TZ: process.env.TZ ?? 'America/Los_Angeles',
...(vault ? { MDBASE_TASKNOTES_PATH: vault } : {}),
...env,
},
});
if (result.error) throw result.error;
return {
code: result.status,
out: result.stdout,
err: result.stderr,
json: () => JSON.parse(result.stdout),
};
}
/** Write a note at `rel` under `root`, creating directories. `frontmatter` is raw YAML text. */
export function writeNote(root, rel, frontmatter, body = '') {
const path = join(root, rel);
mkdirSync(dirname(path), { recursive: true });
writeFileSync(path, `---\n${frontmatter.trim()}\n---\n${body}`);
return path;
}
export function readNote(root, rel) {
return readFileSync(join(root, rel), 'utf8');
}
/** Like ticket(), but asynchronous, so a test can act while the CLI is held by TICKET_TEST_HOLD. */
export function ticketAsync(args, { vault, home = makeHome(), cwd = home, env = {} } = {}) {
const child = spawn(process.execPath, [BIN, ...args], {
cwd,
env: { PATH: process.env.PATH, HOME: home, TZ: process.env.TZ ?? 'America/Los_Angeles', ...(vault ? { MDBASE_TASKNOTES_PATH: vault } : {}), ...env },
});
let out = '';
let err = '';
child.stdout.on('data', (d) => (out += d));
child.stderr.on('data', (d) => (err += d));
return new Promise((resolve, reject) => {
child.on('error', reject);
child.on('close', (code) => resolve({ code, out, err, json: () => JSON.parse(out) }));
});
}
/** Poll until `path` exists. */
export async function waitForFile(path, timeoutMs = 5000) {
const deadline = Date.now() + timeoutMs;
while (!existsSync(path)) {
if (Date.now() > deadline) throw new Error(`timed out waiting for ${path}`);
await new Promise((r) => setTimeout(r, 10));
}
}
@@ -0,0 +1,165 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeVault, readNote, ticket, writeNote } from './helpers.mjs';
const EFFORT = 'TaskNotes/Dev/repo/Effort';
const TODAY = new Date().toLocaleDateString('en-CA', { timeZone: 'America/Los_Angeles' });
const BODY = '## What to build\n\n## Notes\n\n## As Built\n';
const t = (status, tags, extra = '') => `status: ${status}\nprojects:\n - "[[Effort]]"\n${extra}tags:\n - task\n${tags.map((x) => ` - ${x}`).join('\n')}\nticket_type: implementation`;
function fixtureVault() {
const vault = makeVault();
writeNote(vault, `${EFFORT}/Effort.md`, 'status: Open\nprojects:\n - "[[repo]]"\ntags:\n - task', '## Destination\n\n## Notes\n\n## As Built\n');
writeNote(vault, `${EFFORT}/A.md`, t('Open', ['agent-step']), BODY);
writeNote(vault, `${EFFORT}/B.md`, t('Open', ['agent-step'], 'blockedBy:\n - uid: "[[A]]"\n reltype: FINISHTOSTART\n'), BODY);
writeNote(vault, `${EFFORT}/C.md`, t('In Progress', ['agent-step'], 'agent_session: other-session\n'), BODY);
writeNote(vault, `${EFFORT}/D.md`, t('Open', ['human-step', 'needs-info']), BODY);
return vault;
}
const record = (vault, ref) => ticket(['show', '--json', ref], { vault }).json();
const run = (vault, args, env) => {
const result = ticket(args, { vault, env });
return result;
};
test('claim: In Progress under the session, from --session or the environment; refused without either', () => {
const vault = fixtureVault();
const flagged = run(vault, ['claim', 'A', '--session', 's1', '--json']);
assert.equal(flagged.code, 0, flagged.err);
assert.equal(flagged.json()[0].status, 'In Progress');
assert.equal(record(vault, 'A').agent_session, 's1');
assert.equal(run(vault, ['claim', 'D'], { CLAUDE_CODE_SESSION_ID: 'from-env' }).code, 0);
assert.equal(record(vault, 'D').agent_session, 'from-env');
const none = run(vault, ['claim', 'A']);
assert.equal(none.code, 1);
assert.match(none.err, /--session or set CLAUDE_CODE_SESSION_ID/);
});
test('claim: refuses a ticket held by another session or currently blocked; --force overrides', () => {
const vault = fixtureVault();
const held = run(vault, ['claim', 'C', '--session', 's1']);
assert.equal(held.code, 2);
assert.match(held.err, /already In Progress under session other-session/);
assert.equal(run(vault, ['claim', 'C', '--session', 'other-session']).code, 0, 'the holder may re-claim');
assert.equal(run(vault, ['claim', 'C', '--session', 's1', '--force']).code, 0);
assert.equal(record(vault, 'C').agent_session, 's1');
const blocked = run(vault, ['claim', 'B', '--session', 's1']);
assert.equal(blocked.code, 2);
assert.match(blocked.err, /blocked by \[\[A\]\]/);
assert.equal(record(vault, 'B').status, 'Open');
assert.equal(run(vault, ['claim', 'B', '--session', 's1', '--force']).code, 0);
});
test('stall and unstall: the tag and the dated note move together', () => {
const vault = fixtureVault();
const stalled = run(vault, ['stall', 'A', 'Which port should it use?']);
assert.equal(stalled.code, 0, stalled.err);
assert.deepEqual(record(vault, 'A').tags, ['task', 'agent-step', 'needs-info']);
assert.match(readNote(vault, `${EFFORT}/A.md`), new RegExp(`## Notes\\n\\n### \\[\\[${TODAY}\\]\\]\\n\\nWhich port should it use\\?\\n\\n## As Built`));
assert.equal(run(vault, ['stall', 'A', 'Again?']).code, 0, 'stalling twice is fine');
assert.deepEqual(record(vault, 'A').tags, ['task', 'agent-step', 'needs-info'], 'no duplicate tag');
assert.equal(run(vault, ['stall', 'A']).code, 1, 'the question is required');
const answered = run(vault, ['unstall', 'A', 'Port 8787.']);
assert.equal(answered.code, 0, answered.err);
assert.deepEqual(record(vault, 'A').tags, ['task', 'agent-step']);
assert.match(readNote(vault, `${EFFORT}/A.md`), /Again\?\n\nPort 8787\.\n\n## As Built/);
assert.equal(run(vault, ['unstall', 'A']).code, 0, 'unstall without text and without the tag is a no-op');
assert.match(run(vault, ['stall', 'A', 'x', '--json']).out, /"needs-info"/);
});
test('close: Done with a completion date, needs-info cleared, actor corrected on request', () => {
const vault = fixtureVault();
run(vault, ['stall', 'A', 'q?']);
const done = run(vault, ['close', 'A', '--json']);
assert.equal(done.code, 0, done.err);
const a = done.json()[0];
assert.equal(a.status, 'Done');
assert.equal(String(a.date_completed), TODAY);
assert.deepEqual(a.tags, ['task', 'agent-step']);
assert.match(readNote(vault, `${EFFORT}/A.md`), /date_completed: '?\d{4}-\d{2}-\d{2}'?\n/);
const swapped = run(vault, ['close', 'D', '--actor', 'agent']);
assert.equal(swapped.code, 0, swapped.err);
assert.deepEqual(record(vault, 'D').tags, ['task', 'agent-step'], 'human-step replaced, needs-info cleared');
const abandoned = run(vault, ['close', 'C', '--wont-do']);
assert.equal(abandoned.code, 0, abandoned.err);
assert.equal(record(vault, 'C').status, 'Wont Do');
assert.equal(String(record(vault, 'C').date_completed), TODAY);
assert.equal(run(vault, ['close', 'B', '--actor', 'robot']).code, 1);
assert.equal(run(vault, ['close', 'Effort', '--actor', 'agent']).code, 1, 'efforts carry no actor tag');
assert.equal(run(vault, ['close', 'B']).code, 0, 'closing works from any status, blocked or not');
});
test('close: an effort with open children is refused, listing them; it closes once they are all completed', () => {
const vault = fixtureVault();
const refused = run(vault, ['close', 'Effort']);
assert.equal(refused.code, 2);
assert.match(refused.err, /still has 4 open tickets/);
assert.match(refused.err, /Open {2}A\n/);
assert.match(refused.err, /In Progress {2}C\n/);
assert.equal(record(vault, 'Effort').status, 'Open');
for (const child of ['A', 'B', 'C']) run(vault, ['close', child]);
run(vault, ['close', 'D', '--wont-do']);
const closed = run(vault, ['close', 'Effort']);
assert.equal(closed.code, 0, closed.err);
assert.equal(record(vault, 'Effort').status, 'Done');
});
test('set: assignment, removal, typed values, and warnings for keys the verbs own', () => {
const vault = fixtureVault();
const ok = run(vault, ['set', 'A', 'ticket_type=research', 'git_branch=feat/x', 'timeEstimate=30', 'priority=3-High', '--json']);
assert.equal(ok.code, 0, ok.err);
const a = ok.json()[0];
assert.equal(a.ticket_type, 'research');
assert.equal(a.git_branch, 'feat/x');
assert.equal(a.timeEstimate, 30);
assert.equal(a.priority, '3-High');
assert.equal(run(vault, ['set', 'A', 'git_branch=']).code, 0);
assert.equal(record(vault, 'A').git_branch, undefined, 'k= removes the key');
assert.equal(run(vault, ['set', 'A', 'timeEstimate=lots']).code, 1);
assert.equal(run(vault, ['set', 'A', 'status=Bogus']).code, 1);
assert.equal(run(vault, ['set', 'A']).code, 1);
assert.equal(run(vault, ['set', 'A', 'nonsense']).code, 1);
const status = run(vault, ['set', 'A', 'status=In Progress']);
assert.equal(status.code, 0);
assert.match(status.err, /warning: .*status set directly/);
assert.equal(record(vault, 'A').status, 'In Progress');
const unknown = run(vault, ['set', 'A', 'made_up=1']);
assert.match(unknown.err, /warning: .*"made_up" is not a field/);
});
test('set: += and -= on the list fields; blockers must resolve; the task tag cannot be removed', () => {
const vault = fixtureVault();
assert.equal(run(vault, ['set', 'A', 'tags+=extra', 'contexts+=Coding', 'projects+=Other']).code, 0);
let a = record(vault, 'A');
assert.deepEqual(a.tags, ['task', 'agent-step', 'extra']);
assert.deepEqual(a.contexts, ['Coding']);
assert.deepEqual(a.projects, ['[[Effort]]', '[[Other]]']);
assert.equal(run(vault, ['set', 'A', 'tags-=extra', 'contexts-=Coding', 'projects-=[[other]]']).code, 0);
a = record(vault, 'A');
assert.deepEqual(a.tags, ['task', 'agent-step']);
assert.equal(a.contexts, undefined, 'an emptied list is removed');
assert.deepEqual(a.projects, ['[[Effort]]']);
const dangling = run(vault, ['set', 'A', 'blockedBy+=Nope']);
assert.equal(dangling.code, 2);
assert.match(dangling.err, /no note titled "Nope"/);
assert.equal(run(vault, ['set', 'A', 'blockedBy+=[[c]]', 'blockedBy+=C']).code, 0);
assert.deepEqual(record(vault, 'A').blockedBy, [{ uid: '[[C]]', reltype: 'FINISHTOSTART' }], 'resolved to the canonical title, once');
assert.equal(run(vault, ['set', 'A', 'blockedBy-=c']).code, 0);
assert.equal(record(vault, 'A').blockedBy, undefined, 'removing the last blocker removes the key');
assert.equal(run(vault, ['set', 'A', 'blockedBy+=Effort']).code, 0);
assert.equal(run(vault, ['set', 'A', 'blockedBy=B']).code, 0);
assert.deepEqual(record(vault, 'A').blockedBy, [{ uid: '[[B]]', reltype: 'FINISHTOSTART' }], '= replaces the list');
const stall = run(vault, ['set', 'A', 'tags+=needs-info']);
assert.match(stall.err, /warning: .*stall and unstall/);
const actor = run(vault, ['set', 'A', 'tags+=human-step']);
assert.match(actor.err, /warning: .*close --actor/);
assert.equal(run(vault, ['set', 'A', 'ticket_type+=x']).code, 1, '+= is for list fields');
const untask = run(vault, ['set', 'A', 'tags-=task']);
assert.equal(untask.code, 2);
assert.ok(record(vault, 'A').tags.includes('task'));
});
@@ -0,0 +1,60 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeVault, ticket, writeNote } from './helpers.mjs';
const EFFORT = 'TaskNotes/Dev/repo/Effort';
const BODY = '## What to build\n\n## Notes\n\n## As Built\n';
const t = (status, tags, extra = '') => `status: ${status}\nprojects:\n - "[[Effort]]"\n${extra}tags:\n - task\n${tags.map((x) => ` - ${x}`).join('\n')}`;
const blockedBy = (title) => `blockedBy:\n - uid: "[[${title}]]"\n reltype: FINISHTOSTART\n`;
function fixtureVault() {
const vault = makeVault();
writeNote(vault, `${EFFORT}/Effort.md`, 'status: Open\nprojects:\n - "[[repo]]"\ntags:\n - task', '## Destination\n\n## Notes\n\n## As Built\n');
writeNote(vault, `${EFFORT}/A.md`, t('Open', ['agent-step']), BODY);
writeNote(vault, `${EFFORT}/B.md`, t('Open', ['agent-step'], blockedBy('A')), BODY);
writeNote(vault, `${EFFORT}/C.md`, t('Open', ['human-step']), BODY);
writeNote(vault, `${EFFORT}/D.md`, t('Open', ['agent-step', 'needs-info']), BODY);
writeNote(vault, `${EFFORT}/E.md`, t('In Progress', ['agent-step']), BODY);
writeNote(vault, `${EFFORT}/F.md`, t('Done', ['human-step'], 'date_completed: 2026-08-01\n'), BODY);
writeNote(vault, `${EFFORT}/G.md`, t('Open', ['agent-step'], blockedBy('F')), BODY);
writeNote(vault, 'TaskNotes/Tasks/H.md', 'status: Open\ntags:\n - task\n - human-step', 'personal, but a human step\n');
return vault;
}
const titles = (vault, args) => {
const result = ticket([...args, '--json'], { vault });
assert.equal(result.code, 0, result.err);
return result.json().map((r) => r.title);
};
test('frontier: Open and unblocked children of the effort; --actor narrows to who may pick it up', () => {
const vault = fixtureVault();
assert.deepEqual(titles(vault, ['frontier', 'Effort']), ['A', 'C', 'D', 'G']);
assert.deepEqual(titles(vault, ['frontier', '[[effort]]', '--actor', 'agent']), ['A', 'G'], 'agent-step, not stalled, not blocked');
assert.deepEqual(titles(vault, ['frontier', 'Effort', '--actor', 'human']), ['C']);
const records = ticket(['frontier', 'Effort', '--json'], { vault }).json();
assert.deepEqual(Object.keys(records[0]).slice(0, 3), ['path', 'title', 'isBlocked']);
assert.ok(records.every((r) => r.isBlocked === false));
});
test('frontier: an unknown or non-effort reference is a refusal; a bad actor is a usage error', () => {
const vault = fixtureVault();
assert.equal(ticket(['frontier', 'Nope'], { vault }).code, 2);
const notEffort = ticket(['frontier', 'A'], { vault });
assert.equal(notEffort.code, 2);
assert.match(notEffort.err, /not an effort/);
assert.equal(ticket(['frontier', 'Effort', '--actor', 'robot'], { vault }).code, 1);
assert.equal(ticket(['frontier'], { vault }).code, 1);
});
test('inbox: human steps and stalled tickets that are not completed, across the whole vault', () => {
const vault = fixtureVault();
assert.deepEqual(titles(vault, ['inbox']), ['C', 'D', 'H']);
const text = ticket(['inbox'], { vault });
assert.equal(text.code, 0, text.err);
assert.match(text.out, /^Open\s+human\s+C\s+TaskNotes\/Dev\/repo\/Effort\/C\.md\n/);
assert.match(text.out, /Open \(needs-info\)\s+agent\s+D\s+/);
ticket(['close', 'C'], { vault });
ticket(['unstall', 'D'], { vault });
assert.deepEqual(titles(vault, ['inbox']), ['H']);
assert.equal(ticket(['inbox', 'stray'], { vault }).code, 1);
});
@@ -0,0 +1,111 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { makeVault, readNote, ticket, writeNote } from './helpers.mjs';
const EFFORT = 'TaskNotes/Dev/repo/Effort A';
const task = (extra) => `status: Open\ncontexts:\n - Coding\n${extra}\ntags:\n - task`;
/** A repo with one effort and five tickets, a personal task, and two non-task notes (one a twin of Delta). */
function fixtureVault() {
const vault = makeVault();
writeNote(vault, `${EFFORT}/Effort A.md`, task('projects:\n - "[[repo]]"'), '## Destination\n\n## Notes\n\n## As Built\n');
writeNote(vault, `${EFFORT}/Alpha.md`, `${task('projects:\n - "[[Effort A]]"')}\n - agent-step\nticket_type: implementation`, '## What to build\n\n- [ ] one\n\n## Notes\n\n## As Built\n');
writeNote(vault, `${EFFORT}/Beta.md`, `${task('projects:\n - "[[Effort A]]"\nblockedBy:\n - uid: "[[Alpha]]"\n reltype: FINISHTOSTART')}\n - agent-step`, '## What to build\n\n## Notes\n\n## As Built\n');
writeNote(vault, `${EFFORT}/Gamma.md`, `status: Done\nprojects:\n - "[[Effort A]]"\nblockedBy:\n - uid: "[[Nope]]"\n reltype: FINISHTOSTART\ntags:\n - task\n - human-step\n - needs-info\ndate_completed: 2026-08-20`, '## Question\n\n## Notes\n\n## As Built\n');
writeNote(vault, `${EFFORT}/Delta.md`, `${task('projects:\n - "[[Effort A]]"\nblockedBy:\n - uid: "[[Gamma]]"\n reltype: FINISHTOSTART')}\n - agent-step`, '## What to build\n\n## Notes\n\n## As Built\n');
writeNote(vault, 'TaskNotes/Tasks/Groceries.md', 'status: Open\ntags:\n - task', 'milk\n');
writeNote(vault, `${EFFORT}/Epsilon.md`, `${task('projects:\n - "[[Effort A]]"\nblockedBy:\n - uid: "[[Delta]]"\n reltype: FINISHTOSTART')}\n - agent-step`, '## What to build\n\n## Notes\n\n## As Built\n');
writeNote(vault, 'Notes/delta.md', 'kind: plain', 'Not a task, and a case-insensitive twin of Delta.\n');
writeNote(vault, 'Random.md', 'kind: plain', 'plain note\n');
return vault;
}
test('show: a path, a bare title, or a wikilink all name the same note, printed verbatim', () => {
const vault = fixtureVault();
const expected = readNote(vault, `${EFFORT}/Beta.md`);
for (const ref of [`${EFFORT}/Beta.md`, `${EFFORT}/Beta`, 'Beta', '[[Beta]]', 'beta', 'Beta.md', '[[somewhere/Beta|alias]]']) {
const result = ticket(['show', ref], { vault });
assert.equal(result.code, 0, `${ref}: ${result.err}`);
assert.equal(result.out, expected, ref);
}
});
test('show: ambiguity and absence are refusals that name the problem', () => {
const vault = fixtureVault();
const ambiguous = ticket(['show', 'delta'], { vault });
assert.equal(ambiguous.code, 2);
assert.match(ambiguous.err, /ambiguous/);
assert.match(ambiguous.err, /TaskNotes\/Dev\/repo\/Effort A\/Delta\.md/);
assert.match(ambiguous.err, /Notes\/delta\.md/);
const missing = ticket(['show', 'Zeta'], { vault });
assert.equal(missing.code, 2);
assert.match(missing.err, /no note titled "Zeta"/);
assert.equal(ticket(['show'], { vault }).code, 1);
});
test('show --json: the record carries path, title, derived isBlocked, raw frontmatter, and body', () => {
const vault = fixtureVault();
const record = ticket(['show', '--json', 'Beta'], { vault }).json();
assert.deepEqual(Object.keys(record).slice(0, 3), ['path', 'title', 'isBlocked']);
assert.equal(record.path, `${EFFORT}/Beta.md`);
assert.equal(record.title, 'Beta');
assert.equal(record.isBlocked, true);
assert.equal(record.status, 'Open');
assert.deepEqual(record.tags, ['task', 'agent-step']);
assert.deepEqual(record.blockedBy, [{ uid: '[[Alpha]]', reltype: 'FINISHTOSTART' }]);
assert.equal(record.priority, undefined, 'type defaults are not invented');
assert.match(record.body, /^## What to build/);
const plain = ticket(['show', '--json', 'Random'], { vault }).json();
assert.equal(plain.isBlocked, false);
assert.equal(plain.kind, 'plain');
});
test('isBlocked follows the blockers live statuses; unresolvable blockers warn and do not block', () => {
const vault = fixtureVault();
const byTitle = Object.fromEntries(ticket(['list', '--json'], { vault }).json().map((r) => [r.title, r]));
assert.equal(byTitle.Beta.isBlocked, true, 'Alpha is open');
assert.equal(byTitle.Delta.isBlocked, false, 'Gamma is done');
const gamma = ticket(['list', '--json', '--effort', 'Effort A'], { vault });
assert.equal(gamma.json().find((r) => r.title === 'Gamma').isBlocked, false);
assert.match(gamma.err, /Gamma: blocker \[\[Nope\]\] does not resolve/);
assert.equal(byTitle.Epsilon.isBlocked, false, 'an ambiguous blocker is ignored');
assert.match(gamma.err, /Epsilon: blocker \[\[Delta\]\] is ambiguous/);
});
test('list: scoped to TaskNotes/Dev unless --all; --effort, --repo, --open, --where AND together', () => {
const vault = fixtureVault();
const titles = (args) => {
const result = ticket(['list', '--json', ...args], { vault });
assert.equal(result.code, 0, result.err);
return result.json().map((r) => r.title);
};
assert.deepEqual(titles([]), ['Alpha', 'Beta', 'Delta', 'Effort A', 'Epsilon', 'Gamma']);
assert.deepEqual(titles(['--all']), ['Alpha', 'Beta', 'Delta', 'Effort A', 'Epsilon', 'Gamma', 'Groceries']);
assert.deepEqual(titles(['--effort', 'Effort A']), ['Alpha', 'Beta', 'Delta', 'Epsilon', 'Gamma']);
assert.deepEqual(titles(['--effort', '[[Effort A]]']), ['Alpha', 'Beta', 'Delta', 'Epsilon', 'Gamma']);
assert.deepEqual(titles(['--repo', 'repo']), ['Effort A']);
assert.deepEqual(titles(['--repo', '[[repo]]']), ['Effort A']);
assert.deepEqual(titles(['--open']), ['Alpha', 'Beta', 'Delta', 'Effort A', 'Epsilon']);
assert.deepEqual(titles(['--where', 'tags.contains("human-step")']), ['Gamma']);
assert.deepEqual(titles(['--effort', 'Effort A', '--open', '--where', 'exists("blockedBy")']), ['Beta', 'Delta', 'Epsilon']);
assert.equal(ticket(['list', '--effort', 'Nope'], { vault }).code, 2, 'an unknown effort is a refusal, not an empty list');
});
test('list --where: an invalid expression is an error, never an empty result', () => {
const vault = fixtureVault();
const result = ticket(['list', '--where', 'status in ["Open"]'], { vault });
assert.equal(result.code, 1);
assert.match(result.err, /query failed/);
assert.equal(result.out, '');
});
test('list: text rows carry status with flags, actor, title, path', () => {
const vault = fixtureVault();
const result = ticket(['list', '--effort', 'Effort A'], { vault });
assert.equal(result.code, 0, result.err);
const lines = result.out.trimEnd().split('\n');
assert.equal(lines.length, 5);
assert.match(lines.find((l) => l.includes('Beta')), /^Open \(blocked\)\s+agent\s+Beta\s+TaskNotes\/Dev\/repo\/Effort A\/Beta\.md$/);
assert.match(lines.find((l) => l.includes('Gamma')), /^Done \(needs-info\)\s+human\s+Gamma\s+/);
assert.equal(ticket(['list', '--repo', 'nothing-here'], { vault }).out, '', 'no rows, no output');
});
@@ -0,0 +1,62 @@
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import { makeHome, makeVault, ticket } from './helpers.mjs';
const configPath = (home) => join(home, '.config', 'mdbase-tasknotes', 'config.json');
function writeConfig(home, config) {
mkdirSync(join(home, '.config', 'mdbase-tasknotes'), { recursive: true });
writeFileSync(configPath(home), JSON.stringify(config));
}
test('vault: --vault beats the environment, which beats the config file', () => {
const home = makeHome();
const [a, b, c] = [makeVault(), makeVault(), makeVault()];
writeConfig(home, { collectionPath: c });
assert.equal(ticket(['vault', '--vault', a], { home, env: { MDBASE_TASKNOTES_PATH: b } }).out.trim(), a);
assert.equal(ticket(['vault'], { home, env: { MDBASE_TASKNOTES_PATH: b } }).out.trim(), b);
assert.equal(ticket(['vault'], { home }).out.trim(), c);
assert.deepEqual(ticket(['vault', '--json'], { home }).json(), { vault: c });
});
test('vault: never falls back to the current directory, even inside a collection', () => {
const home = makeHome();
const result = ticket(['vault'], { home, cwd: makeVault() });
assert.equal(result.code, 1);
assert.equal(result.out, '');
assert.match(result.err, /ticket vault --set/);
});
test('vault --set refuses a directory that is not a collection with a task type', () => {
const home = makeHome();
const notACollection = ticket(['vault', '--set', makeHome()], { home });
assert.equal(notACollection.code, 2);
assert.match(notACollection.err, /not an mdbase collection/);
const noTaskType = makeVault();
rmSync(join(noTaskType, '_types', 'task.md'));
const missingType = ticket(['vault', '--set', noTaskType], { home });
assert.equal(missingType.code, 2);
assert.match(missingType.err, /no "task" type/);
assert.equal(existsSync(configPath(home)), false, 'nothing was written');
});
test('vault --set records the path and preserves other keys in the file', () => {
const home = makeHome();
const vault = makeVault();
writeConfig(home, { language: 'en' });
const set = ticket(['vault', '--set', vault, '--json'], { home });
assert.equal(set.code, 0, set.err);
assert.deepEqual(set.json(), { vault });
assert.deepEqual(JSON.parse(readFileSync(configPath(home), 'utf8')), { language: 'en', collectionPath: vault });
assert.equal(ticket(['vault'], { home }).out.trim(), vault);
});
test('unknown verbs and options are usage errors', () => {
assert.equal(ticket(['bogus']).code, 1);
assert.equal(ticket(['vault', '--bogus']).code, 1);
assert.equal(ticket([]).code, 1);
assert.equal(ticket(['--help']).code, 0);
});
+13 -6
View File
@@ -134,6 +134,12 @@ install_common() {
# Run stow now as soon as it's available so that config files are in # Run stow now as soon as it's available so that config files are in
# place before more services are started or installed. # place before more services are started or installed.
#
# ~/.local/lib must exist first: stow only links what is missing, so with
# no such directory it would fold ~/.local/lib itself into a symlink to this
# repo, and every later user-level install (pip, etc.) would land in the
# checkout.
mkdir -p "${HOME}/.local/lib"
./run-stow.sh ./run-stow.sh
# NOTE: claude was installed using the install script: # NOTE: claude was installed using the install script:
@@ -148,6 +154,11 @@ install_common() {
# Should be run after ./run-stow.sh. # Should be run after ./run-stow.sh.
mise install mise install
# Dependencies of the `ticket` CLI (dev-tickets skill). Runs under the node
# mise just installed; the package's own lockfile pins the versions.
# (npm ci --prefix would compare the lockfile against this directory.)
(cd "${HOME}/.local/lib/dev-tickets" && npm ci)
# Install tailscale # Install tailscale
# https://tailscale.com/kb/1511/install-fedora-2 # https://tailscale.com/kb/1511/install-fedora-2
@@ -207,13 +218,9 @@ install_desktop() {
install_server() { install_server() {
# Packages only needed on a headless server. # Packages only needed on a headless server.
# #
# tmux now comes from mise (install_common), pinned in config.toml, so both # bubblewrap, socat - For anthropic-srt
# machines get the same version -- nothing server-specific is needed. To roll
# back to the distro package, restore: sudo apt install -y tmux
# #
# NOTE: Debian may lack the tmux-256color terminfo entry (it's in the sudo apt install -y bubblewrap socat
# ncurses-term package); install it if tmux complains about the terminal.
sudo apt install -y bubblewrap
} }
main() { main() {