Compare commits

..
10 Commits
Author SHA1 Message Date
tgrosinger fe449d5682 Tmux: Switch to previous session after killing 2026-07-06 16:50:34 -07:00
tgrosinger 419b4ca6bc Neovim: Fix <C-e> hotkey in Octo buffers 2026-07-06 14:33:08 -07:00
tgrosinger 243b9810c8 Revert "tmux: Extended keys"
This reverts commit 3e521b1b90.
2026-07-06 08:54:29 -07:00
tgrosinger d6bed7a5f5 Neovim: Enable claude-review and disable code-review
I was not really using code-review and the keybindings conflicted with
my new claude-review extension. Claude review is installed from a local
directory.
2026-07-06 08:54:24 -07:00
tgrosinger 5a839c4051 Claude: Remove pr-walkthrough skill
The skill I am using for pr-walkthrough now lives in the claude-review
repo and has been adjusted to output structured json that can be viewed
in my Neovim plugin.
2026-07-06 08:54:24 -07:00
tgrosinger 590c2ce2e7 Pi: Install 2026-07-06 08:54:24 -07:00
tgrosinger a2915b9ffe Neovim: Install updates 2026-07-06 08:54:24 -07:00
tgrosinger e52b60547b Neovim: Show hidden and ignored files in snacks explorer by default 2026-07-06 08:54:24 -07:00
tgrosinger 3e521b1b90 tmux: Extended keys 2026-07-06 08:54:24 -07:00
tgrosinger 71454f4aae Ghostty: Switch from Alacritty 2026-07-06 08:54:24 -07:00
108 changed files with 190 additions and 4976 deletions
-3
View File
@@ -6,6 +6,3 @@
# Python
*.pyc
# dev-tickets CLI dependencies, installed by install-packages.sh (npm ci)
home/.local/lib/dev-tickets/node_modules/
-3
View File
@@ -1,3 +0,0 @@
[submodule "mattpocock-skills"]
path = mattpocock-skills
url = ssh://git@git.grosinger.net:22322/tgrosinger/mattpocock-skills.git
-4
View File
@@ -6,7 +6,3 @@
`run-stow.sh` just links the dotfiles from this repo into their correct locations.
## Docs
[`docs/claude-resource-limits.md`](docs/claude-resource-limits.md) — why `dev-claude` launches through `systemd-run` into `claude.slice`, and what breaks if that wrapper is simplified.
+8
View File
@@ -0,0 +1,8 @@
#/bin/bash
podman run --rm -i \
-v ${HOME}/Music:/downloads:z \
--userns keep-id:uid=1000,gid=1000 \
--entrypoint scdl \
yt-dlp:latest $@
+8
View File
@@ -0,0 +1,8 @@
#/bin/bash
podman run --rm -i \
-v ${HOME}/Music:/downloads:z \
--userns keep-id:uid=1000,gid=1000 \
--entrypoint spotdl \
yt-dlp:latest $@
+7
View File
@@ -0,0 +1,7 @@
#/bin/bash
podman run --rm \
-v ${HOME}/Videos/youtube:/downloads:z \
--userns keep-id:uid=1000,gid=1000 \
yt-dlp:latest -S 'res:1080' $@
+7
View File
@@ -0,0 +1,7 @@
#/bin/bash
podman run --rm \
-v ${HOME}/Videos/youtube:/downloads:z \
--userns keep-id:uid=1000,gid=1000 \
yt-dlp:latest --embed-metadata -o "%(playlist)s/%(playlist_index)s - %(title)s.%(ext)s" -S 'res:1080' $@
-208
View File
@@ -1,208 +0,0 @@
# Claude Code resource limits
## The problem this solves
Runaway subprocess trees under Claude Code — parallel vitest workers, `tsc`, eslint — periodically locked this machine up hard enough that rebooting was the only way out.
The cause is **not** CPU starvation. With 16 cores the scheduler timeslices through a busy load fine. It is memory exhaustion driving **zram thrash**: swap on this machine is 8 GiB of zram *only*, so swapping consumes physical RAM and burns CPU compressing, which frees less memory, which swaps harder. The kernel OOM killer fires far too late to save the interactive session, and sway stops being scheduled long enough to accept input.
At the time this was set up, nothing on the box caught that spiral:
| Mechanism | State |
| --- | --- |
| `kernel.sysrq` | `16` — only `sync` permitted, so `Alt+SysRq+F` was blocked |
| `systemd-oomd` | inactive and disabled |
| `earlyoom` | not installed |
| `sshd` | inactive — no remote rescue path |
There was no escape hatch, which is why rebooting was the only recovery.
## The fix
Every Claude session launched through `dev-claude` runs inside a transient systemd scope placed in **`claude.slice`**, which carries a hard memory ceiling. A runaway tree now hits that ceiling and is OOM-killed *inside its own cgroup*, while sway and everything else keep running.
This needs no root: `cpu memory pids` are already delegated to the user manager (`Delegate=yes` on `user@1000.service`).
### Files involved
| File | Role |
| --- | --- |
| `home/.config/systemd/user/claude.slice` | The limits themselves |
| `home/.config/fish/functions/dev-claude.fish` | Places each session into the slice |
### The launcher
`dev-claude.fish` builds a launch prefix once and uses it at all four launch sites:
```fish
set -l launch /usr/bin/env claude
if command -q systemd-run
set launch systemd-run --user --scope --quiet --collect --slice=claude.slice -- /usr/bin/env claude
end
```
Because `systemd-run --scope` **execs** rather than forks, the resulting process tree and command line are byte-identical to running `claude` directly. The only difference is which cgroup it lands in.
## Three non-obvious things
These are the parts that will look wrong later and get "cleaned up". Don't.
### 1. `/usr/bin/env` is load-bearing
`systemd-run` resolves its target through `PATH` and **rewrites `argv[0]` to an absolute path**:
```
$ systemd-run --user --scope --quiet -- argvprobe.sh
argv0=[/tmp/argvprobe.sh] # not "argvprobe.sh"
```
`~/.tmux.conf` sets `@resurrect-processes '"claude->dev-claude *"'`, and tmux-resurrect matches that with an **anchored** regex — `scripts/process_restore_helpers.sh`:
```bash
if [[ "$pane_full_command" =~ (^${match} ) ]] || [[ "$pane_full_command" =~ (^${match}$) ]]; then
```
So a bare `systemd-run … -- claude` makes the saved command `/home/tgrosinger/.local/bin/claude --session-id …`, which fails `^claude ` and **silently stops restoring Claude panes** — breaking the exact thing `dev-claude` exists to provide. There is no error; restore just quietly does nothing.
Interposing `env` fixes it, because `env` re-execs its target with `argv[0]` set to the bare name:
```
$ systemd-run --user --scope --quiet -- /usr/bin/env sleep 137
cmdline: [sleep 137] # bare argv[0], as if run directly
ppid: bash # systemd-run exec'd; no extra process in the tree
```
`env` also does the PATH lookup that `command claude` used to do here, so a fish function or alias named `claude` still cannot shadow the real binary.
### 2. `dev-claude` must not `exec`
This one bit once already: for months every Claude pane was saved, matched nothing, and restored nothing — silently.
tmux-resurrect's `ps` save strategy does not read the pane process. It reads the pane process's **children** (`save_command_strategies/ps.sh`):
```bash
ps -ao "ppid,args" | sed "s/^ *//" | grep "^${PANE_PID}" | cut -d' ' -f2-
```
`exec` makes Claude *become* the pane pid, so that grep returns Claude's own subprocesses instead of Claude. The saved `pane_full_command` came out as `socat UNIX-LISTEN:/tmp/claude-http-….sock …` — or empty, when nothing happened to be running — never `claude --session-id …`. Both fail `^claude `, so `@resurrect-processes` never fires.
Running Claude as a plain foreground child keeps fish at the pane pid, which is exactly where resurrect looks:
```
$ fish -c 'echo $fish_pid; /usr/bin/env sleep 3' # no exec
286 285 fish -c …
317 286 sleep 3
-> resurrect saves: [sleep 3]
$ fish -c 'echo $fish_pid; exec /usr/bin/env sleep 3'
328 285 sleep 3
-> resurrect saves: []
```
The cost is that the pane returns to a fish prompt when Claude exits rather than closing. That is the intended trade — a `dev` layout does not collapse when a session ends.
### 3. `command` cannot be used in the fallback
The natural fallback is `set -l launch command claude`. While `dev-claude` still used `exec`, fish rejected it outright:
```
fish: The expanded command is a keyword.
set -l launch command echo; exec $launch ok
^~~~~~^
```
`exec` will not accept an expansion that begins with a builtin. That specific constraint is gone with `exec`, but `/usr/bin/env claude` stays in both branches: it gives the same shadowing guarantee, and keeping the two branches identical means the saved command line does not depend on whether `systemd-run` was found.
## Limits, and why each one
Set on the **slice**, not on each scope, so all concurrent sessions share one budget. Per-scope limits would let four sessions claim 4 × 14 GiB and defeat the point.
| Setting | Value | Reason |
| --- | --- | --- |
| `MemoryHigh` | `10G` | Soft ceiling — throttle and reclaim first, so there's back pressure before anything dies |
| `MemoryMax` | `14G` | Hard ceiling — OOM-kill inside the cgroup. Leaves ~16 GiB of 30 GiB for everything else, which normally sits near 13 GiB |
| `MemorySwapMax` | `2G` | The zram-specific piece. Uncapped, the slice can push 8 GiB into zram, which itself occupies physical RAM — that's the thrash spiral |
| `CPUWeight` | `50` | Only bites under contention, so nothing slows down when the box is idle |
| `CPUQuota` | `1200%` | Reserves ~4 of 16 cores for sway and the compositor unconditionally |
| `TasksMax` | `2048` | Bounds pid exhaustion from a runaway worker pool |
## Operating it
Kill every Claude session at once, without touching anything else:
```bash
systemctl --user kill claude.slice
```
Watch live usage against the caps:
```bash
systemd-cgtop --user
systemctl --user status claude.slice
```
Tune without restarting running sessions (edit the unit file to persist):
```bash
systemctl --user set-property claude.slice MemoryMax=16G
```
After editing the unit file:
```bash
systemctl --user daemon-reload
```
## Verifying it actually works
**The check that matters.** If the unit file is missing or `daemon-reload` was skipped, systemd **silently auto-creates `claude.slice` with no limits** — sessions launch fine, appear healthy, and are completely unbounded. There is no warning. Always confirm the real value:
```bash
cat /sys/fs/cgroup/user.slice/user-$(id -u).slice/user@$(id -u).service/claude.slice/memory.max
# must print 15032385536, NOT "max"
```
Confirm a running session is actually inside the slice:
```bash
systemd-cgls --user-unit claude.slice
```
Confirm tmux-resurrect is unaffected — this is the string its `ps` save strategy reads. It must be non-empty, must name `claude` and not one of its subprocesses, and must show a **bare** `claude`, not an absolute path:
```bash
ps -ao ppid,args | grep "^$(tmux display -p '#{pane_pid}') "
# claude --session-id <uuid>
```
Then check what actually landed on disk, which is the thing restore reads:
```bash
grep -a claude ~/.local/share/tmux/resurrect/last | cut -f11
# :claude --session-id <uuid>
```
### Testing containment safely
Do **not** force an OOM inside `claude.slice` to test it. The kernel picks the largest consumer in the cgroup, which is very likely to be a real session rather than the test process. Use an unrelated top-level slice instead — note that systemd treats `-` as a hierarchy separator, so a name like `claude-test.slice` would nest *inside* `claude.slice` and hit the same problem:
```bash
systemd-run --user --scope --quiet --collect --slice=capcheck.slice \
-p MemoryMax=512M -p MemorySwapMax=0 \
-- python3 -c "b=bytearray()
while True: b.extend(bytes(1<<20))"
```
Expect `Killed`, with the desktop unaffected.
## Known gap
Typing plain `claude` instead of `dev-claude` **bypasses the cap entirely**. Closing that would mean adding a `claude` fish function delegating to `dev-claude` — safe from recursion, since `env` resolves the binary through `PATH` and never re-enters fish.
## Deliberately not done here
Independent of this, and all requiring root:
- `kernel.sysrq=1`, restoring `Alt+SysRq+F` as an in-kernel escape hatch that works even when userspace is fully starved
- `earlyoom` (packaged in Fedora), which would also catch non-Claude runaways
- enabling `sshd` for remote rescue from another device
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/codebase-design
@@ -1,122 +0,0 @@
---
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.
```
-125
View File
@@ -1,125 +0,0 @@
# Why dev-tickets is shaped this way
`SKILL.md` is the what and the how. This is the why — the constraints that forced each choice, the alternatives rejected, and the vault settings the model quietly depends on. Read it before changing the model; most of the obvious simplifications have already been tried on paper and don't survive contact with the tooling.
Designed 2026-08-10, replacing per-repo `.scratch/` directories. Revised 2026-08-28: the model is unchanged, but the mechanics moved from `tn` + `obsidian` to the file-based `ticket` CLI — see [2026-08-28: file-based on every host](#2026-08-28-file-based-on-every-host). The sections between here and there describe the constraints as they stood on the desktop with Obsidian running; where they talk about `tn` or `obsidian`, that is history.
## The goal
Everything about a piece of work — the idea, the planning, the spec, and the implementation tickets — in one place, queryable, with real dependency edges instead of prose cross-references like "that is ticket 03".
## The constraints that shaped it
These are properties of the tools, not preferences. Most of the design falls out of them.
**Grouping rides on `projects`, not on folders.** `path` is filterable, so directory layout *could* scope a query — but archiving moves a note to `TaskNotes/Archive/` and changes its path, while its `projects` edges survive. Folders organise notes for a human; `projects` is what a query can trust.
**`tn update` cannot write a task's body.** There is no `details` flag on update, so a body can only be set at creation — which `tn create --details-file` now does. Editing an existing body still goes through `obsidian`.
**Three CLI gaps were closed rather than worked around** (2026-08-11, effort `tn API feature gaps`): `--add-tags`/`--add-contexts`/`--add-projects` were silent no-ops that returned 200 with the task unchanged; `blockedBy` was invisible to both `tn update` and `--filter`; and `PUT {"blockedBy": []}` could not clear the field, because the deletion pass fired only on a literal `undefined` that JSON cannot express. The plugin gained `POST /api/tasks/:id/{tags,contexts,projects,dependencies}` and the CLI was wired to them. The two-CLI split below survives this, but shrank to what `tn` genuinely does not model.
**Subtasks are `projects`.** There is no parent field anywhere in TaskNotes. A task is a subtask of whatever its `projects` array wikilinks to, and the parent can be any note — including one that doesn't exist. The vault's own `TaskNotes/Views/relationships.base` defines its Subtasks view exactly this way.
**`isBlocked` is derived.** The plugin maintains a dependency index and computes `isBlocked` from `blockedBy` plus the live status of each blocker, exposing it as a read-only field on every task. Storing a `blocked` status alongside it would be a second source of truth that drifts.
## Choices, and what lost
**Vault over `.scratch/`.** `.scratch/` worked from a plain filesystem with no daemon; the vault needs Obsidian running. That's a real availability regression, accepted because a single location for idea → planning → implementation was the whole point.
Moving creation to `tn create` (2026-08-11) deepened it. Tickets used to be written straight to disk, so a stopped Obsidian cost only status updates; now it costs creation as well. Reading a ticket never depended on Obsidian and still doesn't — the notes are plain markdown on disk. The trade was accepted because hand-authoring had to reproduce the plugin's `fieldMapping` (`scheduled``date_scheduled`) by hand, and silently produced a note the plugin then rewrote.
**The spec and the planning map are one note.** They were briefly separate — a plain "effort note" holding the wayfinder map, with the spec as a task beneath it — because a map isn't naturally a task. That split existed only to give the map a non-task home, and it cost a query: with tickets two levels down, no single `projects:contains` reached the whole effort. Collapsing them is also closer to the truth, since the map's Destination is what graduates into the spec. The cost is that decision and implementation tickets become siblings with no structural distinction, which `ticket_type` now carries instead.
**Rejected: dual-parenting.** Giving implementation tickets both `[[Spec]]` and `[[Effort]]` would make one query span everything, but it overloads `projects` to mean both "hierarchy parent" and "effort membership" — the effort's Subtasks view would flatten the spec and every ticket into one list.
**Rejected: a tag per repo and per effort.** `tags:contains:"effort/network-isolation"` scopes at any depth and would have worked, but it creates unbounded tag vocabulary that duplicates the directory path. The `[[repo]]` wikilink gets the same result with the mechanism already in use.
**Repo project notes are optional** (revised 2026-08-11). They were originally specified as one plain note per repo. Two findings demoted them. Grouping never needed the note: `projects` matches on the frontmatter value, so a dangling `[[repo]]` queries identically to a resolved one — `[[tasknotes-cli]]` had been running that way unnoticed. And the documented `projects: []` frontmatter was inert, since the Subtasks filter opens with `file.hasTag("task")` and a project note deliberately has no such tag, so nothing ever read it.
What survives is narrower and worth keeping: the note's *path* is what `relationships.base` compares `this.file` against, so a repo with no note has no page to view its efforts on. That makes the note a landing page, created on demand, with a free-form body. It still must not be a task — one there would show up in `tn list` as something to do.
**The link binds without a note.** Making the note optional does not make an unmatched `[[repo]]` inert: Obsidian resolves case-insensitively across the whole vault, so `[[obsidian]]` attaches the repo's tickets to a personal `Obsidian.md` at the vault root and lists them in *its* Subtasks view. Queries stay correct throughout, which is why it goes unnoticed. Title collisions are checked by looking, with `-iname`.
**Tags for routing, a field for type.** The routing tags sit on the note as tags; `ticket_type` is a registered user field instead, because TaskNotes rewrites frontmatter on update and an unregistered key isn't guaranteed to survive the round-trip.
**Actor and stall are two axes, not one** (revised 2026-08-11). The original vocabulary was `needs-info` / `ready-for-agent` / `ready-for-human` — three mutually exclusive values answering *who acts next*. That framing made every value transient: claiming or closing a ticket should logically have cleared or rewritten the tag, and none of the transitions were written down. The first six real tickets all reached `Done` still carrying `ready-for-agent`, which under that reading is drift.
Renaming the actor values to `agent-step` / `human-step` changes the question the tag answers from "who is queued next" to "whose step is this", which is **durable** — true at creation and still true after completion, where it becomes a record of who did the work. That makes the observed behaviour correct rather than sloppy, and it costs nothing to maintain.
`needs-info` does not belong on that axis: it is not an actor, it is a stall. It became an orthogonal flag, so `agent-step` + `needs-info` reads as "agent work, currently stalled on a question" — a state the old mutually-exclusive vocabulary could not express at all without lying about who owned the ticket.
The one maintained rule is that the actor tag is **corrected at close** if the other actor did the work. Pure write-once was considered and rejected: it makes `agent-step AND status:Done` mean "was scoped for an agent" rather than "an agent shipped it", and the second is the question worth being able to ask.
The full transition table is in `SKILL.md`; it exists because its absence was the single largest source of uncertainty in the model.
**`setup` instead of wayfinder's `task`.** Upstream's fourth type is `task`, which collides twice here: with `implementation` (both mean "do work") and with the `task` tag that makes a note a task at all. `setup` names what the type is for — groundwork whose result later tickets depend on.
**Statuses collapse, nothing is archived.** Upstream ran two deliberately separate vocabularies: `blocked`/`needs-info`/`ready-for-agent`/`ready-for-human`/`wont-do`/`complete` for implementation tickets, and `claimed`/`resolved`/`out-of-scope` for decision tickets. Both fold onto the same four TaskNotes statuses. Archiving would have been the natural home for `wont-do`, but it moves the file to `TaskNotes/Archive/` and breaks the directory layout — hence the `Wont Do` status.
**Rejected: `curl`.** `PUT /api/tasks/:id` has no validation and accepts `blockedBy` and `projects` directly, which made it the obvious escape hatch until the Obsidian CLI turned out to cover the same ground with no auth token and no JSON assembly.
**Rejected: MCP.** The plugin ships an MCP server, currently disabled. Its `create_task` and `update_task` tools validate through a zod schema that has no `blockedBy` key, so an extra field is *stripped* before the handler sees it — strictly worse than the unvalidated HTTP route.
## Vault settings this depends on
Invisible from `SKILL.md`, and the model breaks quietly without them.
- **mdbase export enabled** — TaskNotes maintains `mdbase.yaml` and `_types/task.md` in the vault root. `ticket` opens the vault through that schema and refuses to run without a `task` type; the completed statuses (`tn_completed_values`) come from it, not from code.
- **Status `Wont Do`**`isCompleted: true` so it drops off the frontier and `ticket` treats it as completed, `excludeFromCycle: true`, `autoArchive: false`.
- **Status `Done` with `autoArchive: false`** — changed from `true` for this system. Auto-archiving would move completed tickets out of their effort directory. Note this is global: personal completed tasks no longer move to `TaskNotes/Archive/` either.
- **User fields** `ticket_type`, `git_worktree`, `git_branch`, `agent_session` — all `text`, hidden from the creation and edit modals via `modalFieldsConfig`, since they're agent-written. Registering them is what puts them in `_types/task.md`, which is the only reason a file-based writer can be schema-faithful.
- **`taskIdentificationMethod: tag`** with `taskTag: task` and empty `excludedFolders` — this is what lets tickets live under `TaskNotes/Dev/` instead of `tasksFolder`. Switching identification to folder-based would orphan every dev ticket at once.
## Confirmed against a running vault
The three behaviours derived from source at design time were observed on 2026-08-11:
1. `tn list --json` does include `isBlocked` on every task, so the frontier query works as written.
2. `tn update --add-tags` was a silent no-op, exactly as suspected — and is now fixed.
3. `obsidian property:set` and `obsidian eval` behave as the app bundle suggested.
One behaviour was *not* anticipated: `obsidian append` writes only at end of file, so a `## Notes` entry lands under the trailing `## As Built` heading instead. `SKILL.md` gives the working form.
Probing `POST /api/tasks` the same day settled how creation works. The endpoint accepts `details` and `customProperties` — which is what made `tn create` able to replace hand-authoring — but **silently ignores** `folder`, `folderPath`, and `path`, filing every new note into `tasksFolder` regardless. `tn create --folder` therefore creates and then moves. Obsidian's watcher re-indexes the move immediately, and wikilinks resolve by basename, so nothing breaks; the note is seconds old and has no inbound links yet.
## Deferred
- The four live `.scratch/` directories (`atribot`, `claude-review`, `gnucash-visualizations`, `pinball-datasette`) stay where they are; new efforts use this system. Completed work isn't worth migrating, and the in-flight `atribot` tickets carry prose cross-references that need rewriting by hand.
- Making `POST /api/tasks` honour a folder would let `tn create --folder` drop its post-create move. The move is a workaround for a server-side gap, and it is the one step that touches the vault outside the plugin.
- `tn update` has no `--prop`, so custom properties still need `obsidian property:set` after creation. Adding it would retire the second CLI from the ticket workflow entirely.
## 2026-08-28: file-based on every host
### The constraint that changed
The skill assumed the desktop: a hardcoded vault path under `~/Documents`, `tn` (which needs the TaskNotes HTTP API inside a running Obsidian) and `obsidian` (which needs the desktop app). On tachi none of that exists — the vault is the obsync container's headless-sync mirror at another path, with no Obsidian process — so a session there could not create, claim, annotate, or close a ticket at all. The "vault over `.scratch/`" trade above had accepted needing Obsidian; a second host made that unacceptable.
### What replaced it
One file-based tool, `ticket`, over `@callumalpass/mdbase` — the same library and `_types/task.md` schema that TaskNotes' mdbase export already maintains in the vault. It reads and writes the markdown directly, so it behaves identically wherever the vault is on disk and whether or not Obsidian is running. The only per-host fact is the vault path, recorded once with `ticket vault --set` in `~/.config/mdbase-tasknotes/config.json` — the same file and key `mtn` would use, so the two could never disagree — and resolved as flag → `MDBASE_TASKNOTES_PATH` → that file, with no current-directory fallback. The model, the shape, the routing tags and the transition table are unchanged; only the mechanics moved, and the verbs enforce the rules the prose used to ask agents to remember (needs-info cleared at close, actor corrected at close, blockers must exist, efforts refuse to close over open tickets).
The package lives in dotfiles at `~/.local/lib/dev-tickets` (plain ESM JavaScript, `node --test`, mdbase the only dependency). There is no shell shim: the entry point's shebang is `#!/usr/bin/env -S MISE_NODE_VERSION=lts node`, which runs it under mise's lts Node from inside any repo regardless of that repo's pin, needs only mise's shims directory on PATH, and resolves `node_modules` from the file's real path so the stow symlink is transparent.
### Facts measured against mdbase 0.2.2 and the live vault
- mdbase's `update` rewrites the entire frontmatter in its own style: single-quoted wikilinks *and* dates where TaskNotes double-quotes and leaves them bare, trailing blank lines dropped, type defaults applied on read but never written. All valid YAML; TaskNotes rewrites in its own style when it next touches the note. `priority` is therefore written explicitly, and keys are emitted in TaskNotes' order, so tool-written notes look native.
- mdbase does not stamp the type's `generated:` fields for a v0.2 type. The tool stamps `date_created` / `date_modified` in TaskNotes' local-offset shape itself.
- mdbase's concurrency check covers only its own stat→write window inside `update()`. The tool records the mtime when it reads, compares in mdbase's `preWriteHook`, and refuses the write (exit 3) if the file changed — no retry; every verb is idempotent and re-running is the recovery. On tachi that guard is the only coordination with the atribot agent container sharing the vault copy, deliberately.
- `in` in `--where` errors with `invalid_expression` through the library; the earlier belief that it "silently returns nothing" was `mtn` swallowing the error.
- On the live vault (3,859 notes, 247 tasks): open ~50 ms, all tasks ~0.6 s, one effort ~20 ms.
- `.mdbase/cache.sqlite` in the vault root is a local artifact; it never appears in the sync log, while `mdbase.yaml` does.
- Vault-wide, case-insensitive title uniqueness is now enforced by `create`, not checked by hand; an ambiguous blocker (two notes with the same title) is treated like an unresolvable one — warned about and ignored — which surfaces the collision instead of guessing which note Obsidian would pick.
### What lost
- **A second host-side sync device, writes through the container, and ACLs.** The mirror at `/tachi/docker/atribot/vault` became user-owned instead; ZFS there is mounted `noacl`, so ACLs were never on the table.
- **`mtn` (mdbase-tasknotes) as the tool.** Its `create` is natural-language only and files into the default tasks folder; its `update` cannot touch projects, blockers, custom properties, or the body; its JSON carries no `isBlocked`. A wrapper was needed regardless, so the wrapper is the whole tool. It is not installed even as the writer of the config file — `ticket vault --set` covers that — and nothing is contributed upstream.
- **A shell shim** (the `tn` pattern). The shebang does the same job with no file to maintain; `tn` already ran off-pin (`installs/node/lts` was not the version pinned in `config.toml`), so the shim bought nothing the shebang does not.
- **`--folder` and `--project` on create.** The tool owns `TaskNotes/Dev/<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.
-304
View File
@@ -1,304 +0,0 @@
---
name: dev-tickets
description: Storage model for development tickets, specs, and planning maps, which live in the Obsidian vault as TaskNotes tasks and are read and written with the `ticket` CLI. Use when creating an effort or spec, writing implementation or decision tickets, finding the frontier, or claiming, annotating, and closing a ticket — and whenever /to-spec, /to-tickets, /wayfinder, or an implementor reads or writes them.
---
# Dev tickets
Tickets, specs, and planning maps live in the Obsidian vault as TaskNotes tasks. They are read and written with **`ticket`**, a file-based CLI that works on the markdown directly — no running Obsidian, no HTTP API — so it behaves the same on every host. This skill owns the storage model: the shape on disk, the metadata, and the verbs that read and write it. The skills that produce tickets (`/to-spec`, `/to-tickets`, `/wayfinder`) own their own process and defer here for everything below.
**Where the vault is** is what `ticket vault` prints. It is configured once per host, and the skill never names a path:
```sh
ticket vault --set <vault path> # once per host; refuses a directory that is not the vault
```
If `ticket` says no vault is configured, ask the user for the path and run that. If `ticket` itself is missing, the dotfiles install script installs it (`npm ci` inside `~/.local/lib/dev-tickets`); the launcher prints the exact command when its dependencies are absent.
## Vocabulary
- **Vault** — the Obsidian vault, wherever it is on this host. The user-facing word for where tickets live.
- **Collection** — the vault as mdbase sees it: `mdbase.yaml` plus the `_types/task.md` schema that TaskNotes maintains. Used only when talking about the tool's mechanics.
- **Repo project** — the `[[repo]]` wikilink every effort in a repo shares. A note at that title is **optional**; see [Repo project notes](#repo-project-notes).
- **Effort** — one feature or change. A task whose body is the planning map, which graduates into the spec.
- **Decision ticket** — a subtask that answers a question. `/wayfinder`'s unit.
- **Implementation ticket** — a subtask that builds something. `/to-tickets`' unit.
- **Frontier** — the tickets that are open and not blocked.
- **Title** — a note's filename stem. It is the note's identity (every edge is a wikilink to it) and is never a frontmatter key.
- **Record** — what `--json` returns for a note: `path`, `title`, the derived `isBlocked`, then the frontmatter as it is on disk. `show --json` adds `body`.
## Shape
```
TaskNotes/Dev/atribot/
├── atribot.md ← repo project note; optional, NOT a task
└── Network isolation/
├── Network isolation.md ← effort task
├── Should obsync share a namespace.md ← decision ticket
└── Split obsync into its own container.md ← implementation ticket
```
One directory per repo, one per effort. Every note in an effort directory sits directly in it — the hierarchy is expressed by `projects`, not by nesting. `ticket create` owns this shape: it derives the directory and the parent link from `--repo` or `--effort`, so a note cannot be filed in the wrong place.
**A title is an identity.** Every parent and blocking edge is a `[[wikilink]]` to a title, so titles are globally unique across the vault and a published note is never renamed. Give each one a name that reads on its own — `Split obsync into its own container`, not `02-split-container`.
Uniqueness is **case-insensitive and vault-wide**, and it binds whether or not you created a note for the title: `projects: [[obsidian]]` would attach every ticket in that repo to a personal `Obsidian.md` anywhere in the vault. `ticket create` refuses a title that any note already carries, and `ticket show <title>` is the pre-check when choosing one (exit 2 means the title is free).
## Frontmatter
`ticket create` writes every one of these in a single call. The middle column names what sets it.
| Field | Set with | Values |
|---|---|---|
| `status` | `--status` at creation, then `claim` / `close` | `Open` · `In Progress` (claimed) · `Done` (complete, resolved) · `Wont Do` (abandoned, out of scope) |
| `contexts` | `--context` on an effort; tickets inherit the effort's | from the repo's `CLAUDE.local.md` |
| `projects` | derived: `--repo R``[[R]]`, `--effort E``[[E]]` | effort → `[[repo]]` · ticket → `[[effort]]` |
| actor tag | `--actor agent\|human` at creation; `close --actor` to correct it | `agent-step` · `human-step` — exactly one, durable, survives completion |
| `needs-info` | `stall` / `unstall` | tag, present only while stalled on an unanswered question |
| `blockedBy` | `--blocked-by` (repeatable) at creation; `set blockedBy+=` / `-=` later | list of `{uid, reltype}`; `reltype` is `FINISHTOSTART`; every blocker must resolve to an existing task |
| `ticket_type` | `--type` | `research` · `prototype` · `grilling` · `setup` · `implementation` |
| `git_branch` | `set git_branch=…` on the effort | effort tasks only; omit entirely when on main/master |
| `git_worktree` | `set git_worktree=…` on the effort | effort tasks only; omit entirely when the main worktree |
| `agent_session` | `claim` | `$CLAUDE_CODE_SESSION_ID`, overwritten each session that works the ticket |
The tool writes the rest itself: the `task` tag, `priority` (the vault's default), `date_created` and `date_modified` (bumped on every write), and `date_completed` at close. It never writes `date_scheduled` or a `title` key.
A ticket on disk reads:
```yaml
---
status: Open
priority: 2-Normal
contexts:
- Obsidian
projects:
- "[[Network isolation]]"
date_created: 2026-08-10T09:12:44.118-07:00
date_modified: 2026-08-10T09:12:44.118-07:00
blockedBy:
- uid: "[[Verify internal network publish]]"
reltype: FINISHTOSTART
tags:
- task
- agent-step
ticket_type: implementation
agent_session: 444e4dcb-de76-4f6b-8c39-7d08d6aa9f5f
---
```
Quoting varies by writer — `ticket` single-quotes wikilinks and dates, TaskNotes double-quotes and leaves them bare — and both are valid YAML; do not "fix" it.
`blocked` is not a status. `isBlocked` is derived from `blockedBy` and the live status of each blocker (TaskNotes does the same on the desktop), so blocking is recorded once, in `blockedBy`, and read back from `isBlocked` in every record. A blocker that does not resolve warns and does not block.
`ticket_type: setup` is groundwork that unblocks a decision — provisioning access, moving data — whose *result* later tickets depend on. It corresponds to `/wayfinder`'s "task" type.
## Body
Effort task — the map first, and the spec replacing `## Destination` once the way is clear. `/wayfinder` and `/to-spec` each own their own section content.
Ticket — `## Question` for a decision ticket, `## What to build` plus acceptance checkboxes for an implementation ticket. A resolved decision ticket gains `## Answer`.
Every effort and ticket task takes the same two trailing sections, which `ticket create` appends when the body lacks them:
```markdown
## Notes
### [[2026-08-10]]
Squid rejects CONNECT to the registry on first run; the allowlist needs the CDN host too, not just the API host.
## As Built
```
`## Notes` is a running log — hurdles, surprises, design changes — appended **while the work happens** with `ticket note`, under a sub-heading wikilinking the current date.
`## As Built` is written at completion with `ticket as-built`, and only when the implementation materially deviated from what the ticket asked for.
## Repo configuration
Each repo's `CLAUDE.local.md` carries the context to stamp on its notes:
```markdown
## Dev tickets
- context: Obsidian
- repo: atribot # optional; defaults to the directory name
```
When a repo has no such block, ask the user for the context and offer to write the block. A guessed context files the effort under the wrong area and stays wrong.
## Creating
An effort names its repo and context; the tool files it at `TaskNotes/Dev/<repo>/<title>/<title>.md` with `projects: [[repo]]` and no actor tag:
```sh
ticket create --repo atribot --title 'Network isolation' --context Obsidian \
--set git_branch=feat/network-isolation \
--body-file - <<'BODY'
## Destination
...
BODY
```
A ticket names its effort; the tool files it beside the effort note with `projects: [[effort]]`, the effort's contexts, the actor tag, and `ticket_type`:
```sh
ticket create --effort 'Network isolation' --title 'Split obsync into its own container' \
--type implementation --actor agent \
--blocked-by 'Verify internal network publish' --blocked-by 'Provision the registry mirror' \
--body-file - <<'BODY'
## What to build
- [ ] Move the compose service into its own namespace
BODY
```
`--type` and `--actor` are required for a ticket and refused for an effort; `--context` is the reverse. `--blocked-by` accepts bare or `[[bracketed]]` titles and every one must exist — a typo'd blocker would never block, so it is an error instead. Create in dependency order. `--set k=v` adds any other field (`git_worktree`, `timeEstimate`); `--json` prints the record.
Creation refuses — and writes nothing — when the target file exists or any note in the vault has the same title case-insensitively (exit 2). Resolving a collision is the user's choice.
### Repo project notes
Optional, and there is no need to create one. `projects: [[atribot]]` groups the repo's efforts whether or not a note by that name exists — queries match on the frontmatter value, so a dangling link queries exactly like a resolved one.
Create one only to give a repo a landing page. Its single job is to be the note Obsidian's Subtasks view renders on: `TaskNotes/Views/relationships.base` matches a task's `projects` against `this.file`, so with no note there is no page to see the effort list on. Nothing reads the note's own frontmatter — it is not a task, so TaskNotes skips it — which makes the body free-form. A description and the repo path is enough:
```markdown
Matrix bot and its companion services, deployed as compose stacks.
Repo: `/home/tgrosinger/code/atribot`
```
`ticket` only makes tasks. Write the file directly at `TaskNotes/Dev/<repo>/<repo>.md`.
## Routing tags
Two independent axes, neither of which is a status. `status` carries the lifecycle; these carry *who does this step* and *whether it can proceed*.
**Actor — `agent-step` or `human-step`.** Exactly one on every ticket, from creation onward. On an open ticket it names the intended next actor; on a closed ticket it records the actor who actually completed the work. It is durable: claiming and closing do not remove it, so a `Done` ticket still records whose step it was. Effort tasks carry no actor tag; only tickets do.
**Stall — `needs-info`.** Transient and orthogonal to the actor. It means the assigned actor cannot proceed without information from someone else; it does not reassign the ticket. Thus `agent-step` + `needs-info` means *agent work awaiting a human answer*, while `human-step` + `needs-info` means *human work awaiting information from another person or source*. Add it the moment work stalls, remove it when the question is answered.
The actor usually follows from `ticket_type`, and `/wayfinder` calls the same distinction HITL vs AFK:
| `ticket_type` | actor | why |
|---|---|---|
| `research` · `implementation` | `agent-step` | an agent drives it alone |
| `grilling` · `prototype` | `human-step` | resolves only through live exchange with the user |
| `setup` | either | depends on whether the agent can do the work itself |
`setup` is the case that must be decided per ticket — which is exactly why the actor is a tag and not derived from `ticket_type` at query time.
| Transition | `status` | actor tag | `needs-info` | verb |
|---|---|---|---|---|
| Created | `Open` | set, exactly one | absent | `create --actor` |
| Claimed | → `In Progress` | unchanged | unchanged | `claim` |
| Stalled on a question | unchanged | unchanged | **add** | `stall` |
| Question answered | unchanged | unchanged | **remove** | `unstall` |
| Reassigned to the other actor | unchanged | **swap** | unchanged | `set tags+=… tags-=…` |
| Completed | → `Done` | **swap if the other actor did the work** | must be absent | `close [--actor]` |
| Abandoned | → `Wont Do` | unchanged | remove if present | `close --wont-do` |
The verbs enforce the two rules that used to be easy to forget: `close` always clears `needs-info`, and `close --actor human` (or `agent`) corrects the actor tag when the other actor finished the work — which is what keeps `agent-step AND status:Done` a truthful record of what agents actually shipped. `set tags+=needs-info` and friends still work as an escape hatch, with a warning naming the verb that would have done it properly.
## Working a ticket
1. **Claim it** before any work, so a concurrent session skips it:
```sh
ticket claim 'Split obsync into its own container'
```
The session id comes from `$CLAUDE_CODE_SESSION_ID` (or `--session`). `claim` refuses a ticket that is blocked or already `In Progress` under another session; `--force` overrides both.
2. **Record the checkout** on the *effort* task. When the branch is not `main` or `master`, set `git_branch`. When working in a linked worktree rather than the repository's main worktree, also set `git_worktree` to the absolute root returned by `git rev-parse --show-toplevel`:
```sh
ticket set 'Network isolation' git_branch=feat/network-isolation git_worktree=/home/tgrosinger/code/atribot-ni
```
Omit `git_branch` on `main`/`master`, and omit `git_worktree` in the main worktree (`ticket set '<effort>' git_worktree=` removes it). If the checkout changes later, update both fields.
3. **Append notes as you go**; each lands under today's `### [[YYYY-MM-DD]]` heading (reused within a day), above `## As Built`:
```sh
ticket note 'Split obsync into its own container' 'Squid rejects CONNECT to the registry on first run.'
ticket note 'Split obsync into its own container' --file - # longer text on stdin
```
4. **Stall it** if you hit a question only the user can answer — the tag and the logged question are one write:
```sh
ticket stall 'Split obsync into its own container' 'Should the registry mirror share the proxy namespace?'
ticket unstall 'Split obsync into its own container' 'Yes — same namespace.'
```
Leave `status` at `In Progress` — the ticket is still yours, it just cannot proceed.
5. **Close it.** Write `## As Built` first if the implementation deviated, then:
```sh
ticket as-built 'Split obsync into its own container' 'Kept the socket; a namespace split was not needed.'
ticket close 'Split obsync into its own container' # Done
ticket close 'Split obsync into its own container' --actor human # the human finished it
ticket close 'Split obsync into its own container' --wont-do # abandoned
```
`close` stamps `date_completed` and clears `needs-info`. Closing an **effort** is refused while any of its tickets is still open — close or `--wont-do` them first; it never cascades.
Closing a ticket unblocks its dependents automatically; there is nothing to update on them.
To resume a ticket: `cd` to its effort's `git_worktree`, then `claude --resume <agent_session>`. When `git_worktree` is absent, use the repository's main worktree: the repo project note records its path when that note exists; otherwise resume from an existing checkout of that repo and use its root from `git rev-parse --show-toplevel`.
## Querying
Every reference (`REF`, `EFFORT`) is a vault-relative path or a title — bare or `[[bracketed]]`, case-insensitive. An ambiguous title is refused with the candidates listed.
The **frontier** of one effort — open and unblocked — and the **agent frontier**, what an agent may pick up right now (also `agent-step` and not stalled):
```sh
ticket frontier 'Network isolation'
ticket frontier 'Network isolation' --actor agent --json
```
**Awaiting a human** — both axes reach the human, so the inbox is their union, minus completed work, across the whole vault:
```sh
ticket inbox
```
Everything else is `list`, scoped to `TaskNotes/Dev/` unless `--all`; the filters AND together:
```sh
ticket list --effort 'Network isolation' # every ticket of the effort, any status
ticket list --repo atribot --open # the repo's open efforts
ticket list --open --where 'status == "In Progress"'
ticket list --all --where 'tags.contains("human-step") && exists("date_completed")'
```
`--where` is mdbase's expression language, not CEL: `==`, `!=`, `&&`, `||`, `!`, `.contains("x")`, `exists("field")`, `file.name`, indexing. `in` and CEL macros (`exists(t, …)`) are rejected as invalid expressions — the tool reports the error rather than returning nothing.
Text output is one row per note — status (with `blocked` / `needs-info` flags), actor, title, path. `--json` prints records; pipe to `jq`:
```sh
ticket frontier 'Network isolation' --actor agent --json | jq -r '.[0].path'
```
## Cheatsheet
```
ticket vault [--set PATH] print the vault path, or record it for this host
ticket show REF print a note verbatim (--json: its record with body)
ticket list [--effort E] [--repo R] [--open] [--where EXPR] [--all] tasks under TaskNotes/Dev (or the whole vault with --all)
ticket frontier EFFORT [--actor agent|human] the effort's tickets that are Open and unblocked
ticket inbox human-step or needs-info, not completed, vault-wide
ticket create --repo R --title T --context C [--set k=v] [--body-file F|-]
ticket create --effort E --title T --type TYPE --actor agent|human [--blocked-by B]... [--body-file F|-]
ticket claim REF [--session ID] [--force] In Progress under this session
ticket note REF TEXT|--file F [--date YYYY-MM-DD] append to ## Notes, above ## As Built
ticket as-built REF TEXT|--file F append under ## As Built
ticket stall REF TEXT|--file F add needs-info and log the question
ticket unstall REF [TEXT|--file F] clear needs-info, optionally logging the answer
ticket close REF [--wont-do] [--actor agent|human] Done (or Wont Do), date_completed, needs-info cleared
ticket set REF k=v k= k+=v k-=v ... edit frontmatter; += / -= on tags, contexts, projects, blockedBy
global: --vault PATH --json --help
```
Exit codes: `1` usage or error, `2` refused (collision, blocked, ambiguous, not found, open children, not a task), `3` the file changed between read and write — nothing was written; re-run the command.
## Guardrails
- **Create a new ticket rather than renaming a published one.** Every inbound `projects` and `blockedBy` edge points at the title.
- **Body by hand is fine; frontmatter goes through `ticket`.** `set` and the verbs stamp `date_modified`, refuse non-tasks, keep the schema's types, and refuse to clobber a file another writer changed underneath them. A direct frontmatter edit does none of that.
- **There is no delete.** `ticket` never removes a note, and neither should you without the user's explicit say-so.
- `ticket` does not talk to Obsidian. When Obsidian is running on the same host it picks the change up like any external edit; when it is not, sync carries it.
Why the model is shaped this way, and what was rejected on the way here: [README.md](./README.md).
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/diagnosing-bugs
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/domain-modeling
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/grill-with-docs
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/productivity/grilling
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/productivity/handoff
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/implement
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/improve-codebase-architecture
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/code-review
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/prototype
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/research
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/resolving-merge-conflicts
@@ -1,71 +0,0 @@
---
name: rewrite-comments
description: Inspect and rewrite unclear source-code comments after tracing the surrounding behavior and contracts. Use when asked to review, improve, clean up, or rewrite comments selected by a Git commit, the current working tree, or a named function or method; function requests may target comments on the declaration, inside its body, or both.
---
# Rewrite Comments
Rewrite comments only after understanding the code well enough to preserve its meaning. Make edits in the current working tree; never rewrite Git history unless explicitly requested.
## Resolve the target
Determine the scope from the request:
- **Commit:** Inspect the commit diff and the complete current versions of the code regions it changes. Treat comments added or modified by the commit, plus comments attached to or inside changed declarations, as candidates. If the relevant code no longer exists in the current tree, report that instead of editing an older revision or history.
- **Working tree:** Inspect staged, unstaged, and untracked changes. Treat comments in or attached to changed declarations as candidates, prioritizing comments changed in the diff.
- **Function:** Locate the named function or method. Include comments documenting the declaration and comments within its body by default. Honor `on`, `above`, or `documentation` as declaration-only, and `within`, `inside`, or `body` as body-only.
Use repository-aware search and language structure where available. Account for overloads, methods with the same name, generated code, and renamed files. Ask for clarification only when multiple plausible targets remain and choosing one would materially change the edits.
Do not broaden the edit scope merely because nearby comments could also be improved. Read outside the scope freely to establish context.
## Build context before editing
Read the full enclosing declaration and enough neighboring code to understand it. Trace relevant definitions and call sites until the important behavior is supported by evidence. Establish, as applicable:
- why the code exists and which callers depend on it;
- accepted inputs, returned values, mutations, side effects, and failure behavior;
- ordering, lifecycle, concurrency, caching, security, and performance constraints;
- invariants, edge cases, compatibility requirements, and intentionally surprising choices;
- types, tests, interfaces, domain documentation, and architectural decisions that define the contract.
Prefer direct evidence from code, tests, and repository documentation. Do not invent intent. If a comment makes a claim that cannot be verified, either make it narrower and factual or remove it.
Stop exploring when the comment's purpose and every retained behavioral claim can be explained from evidence. Avoid tracing unrelated parts of the system.
## Judge each candidate
Keep an accurate comment when it already adds durable information. Rewrite or remove a comment when it is unclear, redundant, misleading, stale, speculative, or coupled to incidental implementation details.
Prioritize comments that capture:
- hidden contracts and invariants;
- non-obvious reasons and tradeoffs;
- caller-visible edge cases or failure behavior;
- constraints imposed by another subsystem, API, format, or compatibility promise;
- deliberate deviations from the obvious implementation.
Avoid comments that:
- narrate syntax or restate names and types;
- describe mechanics that are immediately apparent from the next few lines;
- duplicate the type system or stable API shape without adding a contract;
- predict future work without an actionable, repository-standard marker;
- mention transient details, line positions, counts, or internal steps likely to drift;
- preserve a confident explanation unsupported by the code.
Retain required legal notices, tool directives, generated-file markers, suppression comments, and structured documentation tags unless the task explicitly includes them and changing them is safe.
## Rewrite
Edit only comments unless the user explicitly requests code changes. Preserve behavior, public API, formatting conventions, comment style, and documentation syntax.
Write concise, direct comments at the narrowest useful location. Explain `why`, `must`, `unless`, or `despite` when those ideas matter. State contracts in terms of observable behavior rather than current implementation. Include inputs and outputs only when their semantics, ownership, units, normalization, sentinel values, or failure modes are not already obvious from code and types.
Delete a comment when removal is clearer than a rewrite. Do not add comments merely to replace every removed one, and do not churn wording without a meaningful clarity or correctness improvement.
## Verify and report
Review the final diff and confirm that edits are limited to the resolved scope and do not alter executable code. Re-read each edited comment against its code and callers. Run repository-required checks for changed files and any broader checks explicitly required by repository instructions; treat failures as blocking.
Summarize which comments changed and the hidden behavior or contract they now clarify. Mention comments intentionally removed, unresolved ambiguity, unverifiable claims, and checks that could not be run.
@@ -1,4 +0,0 @@
interface:
display_name: "Rewrite Comments"
short_description: "Rewrite code comments around selected changes"
default_prompt: "Use $rewrite-comments to inspect and improve comments in my working tree."
@@ -1,121 +0,0 @@
---
name: senior-code-review
description: Perform a thorough, read-only review of a pull request, branch, commit, working-tree diff, or named files. Use when asked to review code.
---
# Senior Code Review
Perform a high-recall review followed by a high-precision verification pass. Behave like a senior engineer who must understand the local system before judging the change.
## Operating contract
- Treat the review as read-only. Do not edit files, apply fixes, change git state, commit, or post comments unless the user explicitly asks in a separate instruction.
- Review the change, not the author's process. Do not excuse a defect because tests pass or because the implementation was difficult.
- Prefer concrete, falsifiable findings over generic advice. Do not report style preferences unless they violate repository conventions or materially harm comprehension.
- Keep distinct findings separate; combine repeated instances of one root cause.
- Investigate enough surrounding code to determine whether a suspected problem is real. The diff is an index, not the full review surface.
- Report no findings when none survive verification. Never invent issues to make a review look thorough.
Read [references/review-lenses.md](references/review-lenses.md) before reviewing. When independent subagents are available, also read [references/reviewer-briefs.md](references/reviewer-briefs.md).
## 1. Establish scope
Use the user's explicit target when supplied. Otherwise choose the first applicable scope:
1. Staged, unstaged, and relevant untracked changes when the working tree has changes.
2. The current branch against its merge base with the default or upstream branch.
3. The most recent commit when no other change exists.
Resolve the base and head before reviewing. Inspect the diff summary, changed paths, commits, and rename/binary status. Stop with a concise explanation if the target is invalid or the diff is empty.
Exclude generated, vendored, lock, and snapshot files from line-by-line review when appropriate, but inspect their semantic effects when dependency, schema, API, or snapshot changes are relevant.
## 2. Build codebase context
Before judging implementation details:
1. Read the applicable repository instructions, including root and path-scoped `AGENTS.md`, `CLAUDE.md`, `REVIEW.md`, `CONTRIBUTING.md`, and relevant engineering or architecture documents.
2. Determine the intended behavior from the user's request, PR or issue text when available, commit messages, specifications, and tests. State any important requirement that remains unavailable.
3. Read each changed source and test file as a whole when feasible, not only the edited hunks.
4. Trace affected entry points, callers, callees, types, data flows, configuration, and persistence boundaries far enough to understand observable behavior.
5. Search for sibling implementations, established helpers, test conventions, error-handling patterns, and architectural precedents.
6. Use focused git history or blame when a surprising invariant, compatibility shim, or architectural choice may be intentional.
Produce a short internal context map: change intent, affected behavior, relevant invariants, architectural boundaries, and verification mechanisms. Share it with review subagents; do not clutter the final response with it unless it explains a finding.
## 3. Inspect tests before implementation
Read changed and nearby tests first. Infer the promised behavior, then challenge the tests:
- Would a plausible broken implementation still pass?
- Do assertions observe externally meaningful behavior or merely repeat mocks, fixtures, implementation details, or values constructed by the test itself?
- Do mocks preserve the real contract, ordering, failure modes, and data shape?
- Does a regression test fail without the fix for the intended reason?
- Are negative, boundary, authorization, error, concurrency, and integration paths covered where risk warrants them?
- Can the test pass vacuously because code was not called, an async result was not awaited, an exception was swallowed, or the assertion is too broad?
Do not equate coverage with correctness. Treat missing tests as a finding only when you can name the meaningful behavior or regression that remains unprotected.
## 4. Run independent review passes
For a non-trivial change, use four independent review seats in parallel when the host supports subagents:
1. Correctness and unintended behavior.
2. Test validity and verification gaps.
3. Security and defensive coding.
4. Architecture, abstractions, and maintainability.
Give every seat the same scope, intent, context map, changed-file list, and read-only constraint. Keep their reasoning isolated. Use the exact contracts in `references/reviewer-briefs.md` and allow them to inspect the repository rather than pasting an enormous diff into each prompt.
If subagents are unavailable or the change is very small, perform the four passes sequentially yourself. Finish one lens before starting the next so concerns do not collapse into a shallow general scan.
Add focused passes for performance, concurrency, migrations, compatibility, observability, accessibility, or dependency risk when the changed surfaces make them relevant.
## 5. Verify candidate findings
Collect candidate findings from every pass, deduplicate by root cause, then challenge them with a fresh verification pass. Use an independent verifier subagent when available.
Keep a bug, security, or test finding only when all applicable statements are true:
- The finding identifies a precise changed or directly affected location.
- The reviewer read the surrounding implementation, callers, guards, types, and relevant tests.
- A concrete input, state, or execution path leads to a specific bad outcome.
- Existing validation, framework behavior, or another layer does not already prevent it.
- The issue is introduced by the change or is directly relevant to safely merging it. Clearly label significant pre-existing issues.
- The severity matches realistic impact and likelihood.
- The proposed direction addresses the root cause without creating a larger problem.
Keep an architecture or maintainability finding only when it identifies a concrete cost such as an invalid dependency direction, duplicated source of truth, leaky boundary, misleading abstraction, excessive coupling, hidden invariant, or materially harder future change. Explain the tradeoff and label non-blocking improvements as suggestions.
Require at least 80% confidence for reported defects. Drop refuted, purely speculative, or unactionable candidates. When uncertainty is important and cannot be resolved, state the missing evidence rather than asserting a defect.
## 6. Report findings
Lead with findings ordered by severity and then by confidence. Use these levels:
- `P0 Critical`: immediate security compromise, data loss, corruption, or broad outage.
- `P1 High`: concrete bug, security vulnerability, broken contract, or invalid test that should block merge.
- `P2 Medium`: real but narrower defect, meaningful defensive-coding gap, or structural problem likely to cause defects.
- `P3 Suggestion`: non-blocking architectural or maintainability improvement with a concrete benefit.
Format each finding as:
```markdown
### [P1][correctness] Short imperative title
`path/to/file.ext:line`
- Evidence: What the code does and the surrounding fact that makes it wrong.
- Failure scenario: The triggering input/state and observable outcome.
- Recommendation: The smallest sound direction for fixing the root cause.
- Confidence: 92%
```
For test findings, name the mutation or broken implementation that would still pass. For security findings, name the trust boundary, attacker-controlled input or capability, and impact. For architecture findings, name the boundary or invariant and compare the proposed structure with the current cost.
After findings, include:
- `Verdict`: `Do not merge`, `Merge after fixes`, or `Ready to merge`.
- `Review coverage`: the scope and lenses actually inspected.
- `Verification gaps`: commands, runtime checks, specifications, or environment access that were unavailable.
If no finding survives verification, say `No verified findings.` and still list material verification gaps. Do not add praise, a diff summary, or generic best-practice advice unless it helps the user act on the review.
@@ -1,14 +0,0 @@
interface:
display_name: Senior Code Review
short_description: Thorough, evidence-backed code review
default_prompt: Use $senior-code-review to perform a thorough, read-only review
of the current changes.
icon_small: assets/icon.svg
icon_large: assets/icon.svg
policy:
products:
- chatgpt
- codex
- api
- atlas
allow_implicit_invocation: true
@@ -1,5 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3 13.5C3 12.1193 4.11929 11 5.5 11H9V21H5.5C4.11929 21 3 19.8807 3 18.5V13.5Z" fill="#FFD400"/>
<path d="M15 7H18.5C19.8807 7 21 8.11929 21 9.5V18.5C21 19.8807 19.8807 21 18.5 21H15V7Z" fill="#F75858"/>
<path d="M9 5.5C9 4.11929 10.1193 3 11.5 3H12.5C13.8807 3 15 4.11929 15 5.5V21H9V5.5Z" fill="#FFA43D"/>
</svg>

Before

Width:  |  Height:  |  Size: 420 B

@@ -1,73 +0,0 @@
# Review lenses
Use every core lens. Expand only the checks relevant to the changed surfaces.
## Contents
- Correctness and unintended behavior
- Test validity
- Security and defensive coding
- Architecture and maintainability
- Conditional lenses
## Correctness and unintended behavior
- Trace happy, error, empty, null, boundary, retry, cancellation, and partial-failure paths.
- Check state transitions, ordering, idempotency, caching, invalidation, and cleanup.
- Look for off-by-one errors, stale state, wrong defaults, lossy conversions, timezone or locale mistakes, and mismatched units.
- Check async ownership, missing awaits, races, deadlocks, double execution, resource leaks, and work after cancellation.
- Compare types and runtime values across boundaries; do not assume static types validate external data.
- Check compatibility with existing callers, serialized formats, database rows, events, CLI flags, environment variables, and public APIs.
- Follow error values and exceptions. Flag swallowed failures, misleading fallbacks, partial writes, and success reported before durable completion.
- Verify deletions and refactors leave no live caller, stale branch, duplicate path, or behavior split between old and new implementations.
- Distinguish behavior newly broken by the change from a nearby pre-existing issue.
## Test validity
- Identify the behavior each test claims to protect and the production branch that implements it.
- Mentally mutate or remove the production behavior. Determine whether the test fails for the intended reason.
- Detect tautological assertions, assertions derived from the same value under test, snapshots accepted without semantic inspection, and tests with no meaningful assertion.
- Check that the exercised path runs: awaited promises, consumed generators, subscribed streams, flushed transactions, rendered components, and invoked callbacks.
- Treat mocks as contracts. Compare mock methods, data, ordering, errors, and side effects with the real dependency.
- Flag tests that verify implementation details while missing observable outcomes.
- Check false positives from broad exception assertions, loose matchers, unconditional waits, retries that mask failure, or fixtures that bypass the behavior.
- For bug fixes, require a regression test that would fail on the old behavior when practical.
- Seek high-risk gaps: permissions, tenant isolation, malformed input, boundaries, failure recovery, concurrency, migrations, and cross-component integration.
- Do not demand a test for trivial wiring already proven by stronger coverage.
## Security and defensive coding
- Identify trust boundaries and attacker-controlled input before applying a checklist.
- Check authentication, authorization, object ownership, tenant isolation, and confused-deputy paths at the point of action.
- Validate and canonicalize input at boundaries; encode output for its destination context.
- Look for injection into SQL, commands, templates, HTML, URLs, headers, logs, paths, deserializers, and dynamic code.
- Check secrets, credentials, tokens, personal data, and sensitive metadata in source, logs, errors, caches, telemetry, URLs, and client-visible responses.
- Review cryptography, randomness, signature verification, token lifetime, replay resistance, secure comparison, and key handling when touched.
- Check filesystem traversal, unsafe temporary files, archive extraction, symlink behavior, permissions, and cleanup.
- Review SSRF, redirect validation, request smuggling surfaces, unbounded downloads, parser limits, and denial-of-service opportunities for network-facing changes.
- Check dependency provenance, pinned integrity, install or build scripts, dangerous configuration defaults, and privilege expansion.
- Prefer fail-closed behavior where authorization or integrity is uncertain, without turning recoverable availability failures into outages.
- Consider abuse cases and defense in depth; report only issues supported by a realistic capability and impact.
## Architecture and maintainability
- Compare the change with existing architectural boundaries and dependency direction.
- Check separation of orchestration, domain logic, persistence, transport, and presentation.
- Identify feature logic leaking into shared infrastructure or transport details leaking into the domain.
- Look for duplicated sources of truth, parallel implementations, inconsistent policy enforcement, and abstractions that conceal rather than remove complexity.
- Challenge both under-abstraction and premature abstraction. Require a named invariant or repeated variation before adding a general layer.
- Check cohesion, coupling, fan-in/fan-out, circular dependencies, global state, hidden temporal coupling, and unclear resource ownership.
- Prefer explicit invariants, narrow interfaces, meaningful types, and canonical helpers over casts, optional fields, catch-all objects, and silent fallbacks.
- Judge readability through control flow, naming, locality, concept count, and how much unrelated context a maintainer must retain.
- Check whether the design supports likely change without optimizing for hypothetical futures.
- When suggesting restructuring, name the move and why it lowers risk: split responsibility, invert dependency, centralize policy, remove pass-through abstraction, model state explicitly, or reuse a canonical path.
- Avoid requesting large refactors unless the current change creates or materially worsens the problem.
## Conditional lenses
- Performance: complexity, repeated I/O, N+1 access, allocations, hot-path blocking, pagination, cache correctness, and backpressure.
- Data changes: forward/backward compatibility, transactionality, rollbacks, backfills, mixed-version deployment, and irreversible loss.
- Distributed systems: retries, duplicate delivery, ordering, idempotency, clock assumptions, split brain, and partial availability.
- UI and accessibility: loading/error/empty states, keyboard and focus behavior, semantics, contrast, responsive behavior, and hydration.
- Observability: actionable errors, stable structured fields, correlation, sensitive-data hygiene, metrics on failure paths, and diagnosable fallbacks.
- Dependencies and configuration: default changes, environment parity, least privilege, reproducibility, version compatibility, and safe rollout.
@@ -1,57 +0,0 @@
# Reviewer briefs
Use these briefs for isolated review seats. Replace placeholders with the established scope and context. Every reviewer must remain read-only and inspect repository files directly.
## Shared preamble
```text
Act as an independent senior code reviewer. Do not edit files or change git state.
Review target: {TARGET}
Intent and requirements: {INTENT}
Changed files: {CHANGED_FILES}
Relevant repository guidance: {GUIDANCE}
Context map and invariants: {CONTEXT_MAP}
Read the relevant diff, complete changed files, surrounding code, and tests. Trace callers or dependencies when needed. Return candidate findings only. For every candidate include file and line, concrete evidence, failure or maintenance scenario, severity, recommendation, and confidence. Do not report generic advice or repeat repository tooling. Mark pre-existing issues explicitly.
```
## Correctness seat
```text
Focus on runtime correctness and unintended behavior. Trace affected flows through callers, boundaries, state transitions, error paths, compatibility contracts, async behavior, cleanup, and edge cases. Find concrete cases where the implementation produces the wrong observable outcome. Challenge assumptions made by types, frameworks, and happy-path tests. Ignore cosmetic style and unproven hypotheticals.
```
## Test seat
```text
Focus on whether the tests genuinely prove the promised behavior. Read tests before implementation. For each suspicious test, name a realistic broken production mutation that would still pass. Check vacuous execution, weak or tautological assertions, unrealistic mocks, missing awaits, swallowed failures, implementation-detail coupling, and high-risk behavioral gaps. Do not equate coverage with correctness or demand low-value tests.
```
## Security seat
```text
Focus on security and defensive coding. Identify trust boundaries, attacker-controlled inputs, sensitive assets, and privileges first. Examine authentication, authorization, isolation, injection, data exposure, unsafe parsing or filesystem/network behavior, cryptography, dependency risk, resource limits, and failure posture. Report only findings with a realistic attacker capability, path, and impact; include relevant defense-in-depth gaps when concrete.
```
## Architecture seat
```text
Focus on architecture, abstractions, readability, and maintainability in the context of this repository. Compare with existing patterns and boundaries. Look for invalid dependency direction, duplicated truth, leaky layers, hidden invariants, excessive coupling, unclear ownership, premature or missing abstractions, and complexity merely relocated. Suggest a specific structural move and explain its benefit and tradeoff. Separate merge-blocking structural regressions from optional improvements.
```
## Verification seat
```text
Act as an adversarial verifier, not another finder. Given the repository, review target, requirements, and candidate findings, verify each candidate independently.
For each candidate:
1. Read the cited code and enough surrounding callers, guards, types, tests, and framework behavior to understand it.
2. Reproduce or reason through the exact input/state and outcome.
3. Decide confirmed, uncertain, or refuted.
4. Check whether the change introduced it or merely exposed a pre-existing issue.
5. Correct severity and recommendation.
6. Merge duplicates that share a root cause.
Return only confirmed findings at 80% confidence or higher, plus unresolved uncertainties where missing evidence is itself important. Preserve concrete non-blocking architecture suggestions, clearly labeled as suggestions. Do not accept a finding because multiple reviewers repeated it.
```
-128
View File
@@ -1,128 +0,0 @@
---
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.
@@ -1,64 +0,0 @@
---
name: simplify-code
description: Simplify recently changed code without changing behavior. Use when asked to clean up, clarify, streamline, refactor, or review an uncommitted diff, staged changes, changes against a Git ref or branch, the last commit, or specified changed files. Restrict edits to changed lines, follow repository guidance, preserve public APIs and semantics, and verify the result with the project's tests and checks.
---
# Simplify Code
Improve the readability and maintainability of a Git-scoped change while preserving its behavior. Treat the existing diff as the edit boundary, not as permission to refactor the surrounding code.
## Resolve the scope
1. Find the repository root with `git rev-parse --show-toplevel` and work from it.
2. Read applicable repository instructions such as `AGENTS.md` and `CLAUDE.md`, including any nearer files that govern the changed paths.
3. Preserve unrelated worktree changes. Never reset, restore, discard, stage, or commit changes unless the user explicitly asks.
4. Select one base scope from the request:
- Default, including staged and unstaged tracked changes: `git diff HEAD`
- Staged only: `git diff --cached`
- Against a ref or branch: `git diff <ref>`
- Last commit: `git diff HEAD~1..HEAD`
5. If the user names files, append `-- <paths...>` and exclude every other path.
6. List statuses with the matching `git diff --name-status` command. For renames or copies, use the destination path.
7. If the default scope is empty and the request refers generally to recent changes, fall back to `HEAD~1..HEAD`. State in the final summary that the last commit was used. Do not use this fallback for an explicitly staged, ref, or file-scoped request.
8. If the resulting scope is empty, report that there is nothing to simplify and stop.
Use `git diff --unified=0 --no-ext-diff <scope> -- <path>` to identify current-file line ranges from each hunk's `+start,count` coordinates. Treat an omitted count as one line and a zero count as a deletion-only hunk with no current lines. Treat an added file as fully in scope. If ranges touch or overlap, treat them as one range.
Do not hand-edit binary files, generated output, minified files, vendored code, lockfiles, or snapshots unless the user specifically includes them and the repository expects manual edits. Report skipped files briefly.
## Simplify within the boundary
Inspect surrounding code for context, but modify only the changed current-file ranges. Work one file at a time so line movement remains manageable. Recalculate that file's diff after editing and confirm no simplification edit escaped the original changed regions.
Prioritize concrete improvements:
- Reduce unnecessary nesting, branching, duplication, and intermediate state.
- Replace unclear names when every required reference can be changed inside the allowed scope.
- Consolidate logic that is already one concern; keep distinct concerns separate.
- Remove dead or redundant code and comments that merely narrate syntax.
- Retain comments that explain intent, constraints, business rules, or non-obvious behavior.
- Prefer straightforward control flow over clever expressions; avoid nested ternaries.
- Follow established project patterns instead of introducing a new abstraction or style.
Preserve observable behavior, error handling, side effects, ordering, concurrency behavior, types, serialization formats, and public APIs. Do not add features or broaden the task. Do not remove a useful abstraction merely to reduce line count.
If an improvement requires touching unchanged code, leave the code alone and mention the opportunity in the final summary. If no worthwhile in-scope improvement exists, make no edit to that region.
## Verify
1. Review the final diff for every edited file and run `git diff --check` with the same scope where applicable.
2. Run the narrowest relevant formatter, linter, type checker, and tests first, using repository-provided commands.
3. Run the broader project verification suite when practical.
4. If verification fails, determine whether the failure was caused by the simplification. Fix in-scope regressions; otherwise preserve the failure output and report it accurately.
5. Reconfirm that behavior and public interfaces did not change and that unrelated files remain untouched.
## Report
Summarize:
- the Git scope reviewed;
- the simplifications made and why they improve clarity;
- checks and tests run, with their results;
- skipped files, pre-existing failures, or worthwhile out-of-scope improvements.
Do not claim a simplification when the code was already clear enough to leave unchanged.
@@ -1,14 +0,0 @@
interface:
display_name: Simplify Code
short_description: Simplify changed code without changing behavior
default_prompt: Use $simplify-code to simplify my recent code changes while preserving
behavior.
icon_small: assets/icon.svg
icon_large: assets/icon.svg
policy:
products:
- chatgpt
- codex
- api
- atlas
allow_implicit_invocation: true
@@ -1,8 +0,0 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M19.58 4.38996C18.05 2.86996 15.68 2.88996 14.18 4.38996L5.26999 13.33C4.25999 14.34 3.84999 14.92 3.59999 16.26L3.06999 19.56C2.88999 20.47 3.50999 21.09 4.43999 20.93L7.74999 20.39C9.06999 20.18 9.64999 19.76 10.66 18.75L19.58 9.82996C21.1 8.30996 21.12 5.93996 19.58 4.40996V4.38996Z" fill="#FEBD08"/>
<path d="M19.58 9.81997C21.1 8.29997 21.12 5.92997 19.58 4.39997C18.05 2.87997 15.68 2.89997 14.18 4.39997L13.77 4.80997L19.18 10.22L19.58 9.81997Z" fill="#FF928C"/>
<path d="M13.7694 4.81308L12.3552 6.22729L17.7646 11.6367L19.1788 10.2224L13.7694 4.81308Z" fill="#D9D9D9"/>
<path opacity="0.5" d="M12.36 6.22998L5.26998 13.34C4.25998 14.35 3.84998 14.93 3.59998 16.27L3.06998 19.57C2.97998 20.02 3.08998 20.4 3.32998 20.65L15.05 8.92998L12.36 6.22998Z" fill="white"/>
<path d="M4.60001 14.05C4.06001 14.69 3.79001 15.27 3.60001 16.26L3.51001 16.81L7.18001 20.48L7.75001 20.39C8.74001 20.23 9.31001 19.96 9.95001 19.41L4.60001 14.05Z" fill="#FFDDBC"/>
<path d="M3.50999 16.8101L3.06999 19.5601C2.88999 20.4701 3.50999 21.0901 4.43999 20.9301L7.17999 20.4801L3.50999 16.8101Z" fill="#4D4D4D"/>
</svg>

Before

Width:  |  Height:  |  Size: 1.2 KiB

-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/tdd
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/productivity/teach
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/productivity/to-questionnaire
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/to-spec
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/to-tickets
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/productivity/wait-what
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/wayfinder
-105
View File
@@ -1,105 +0,0 @@
---
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.
@@ -1,290 +0,0 @@
#!/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)
}'
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/engineering/wizard
-1
View File
@@ -1 +0,0 @@
../../../mattpocock-skills/skills/productivity/writing-for-agents
+4 -11
View File
@@ -64,11 +64,7 @@ fi
alias tmux="tmux -2"
alias grep="grep --color=auto"
if command -v brew >/dev/null 2>&1; then
eval "$(brew shellenv)"
elif [[ -x /home/linuxbrew/.linuxbrew/bin/brew ]]; then
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
fi
eval "$(/home/linuxbrew/.linuxbrew/bin/brew shellenv)"
# Fix gpg signing
# https://github.com/keybase/keybase-issues/issues/2798
@@ -82,20 +78,17 @@ export TERM="xterm-256color"
export EDITOR=$(which nvim)
# Adding applications to path
if [[ -d ${HOME}/.local/bin ]] && [[ ":${PATH}:" != *":${HOME}/.local/bin:"* ]]; then
export PATH=${HOME}/.local/bin:${PATH}
if [[ -d ${HOME}/.dotfiles/bin/linux ]]; then
export PATH=$PATH:${HOME}/.dotfiles/bin/linux
fi
if [[ -d ${HOME}/bin ]]; then
export PATH=$PATH:${HOME}/bin
fi
eval "$(mise activate bash)"
# Enable atuin
# https://docs.atuin.sh
if command -v atuin >/dev/null 2>&1; then
eval "$(atuin init bash)"
fi
eval "$(atuin init bash)"
# fzf
#eval "$(fzf --bash)"
-27
View File
@@ -1,27 +0,0 @@
#!/bin/bash
# Source: https://github.com/mattpocock/skills
INPUT=$(cat)
COMMAND=$(echo "$INPUT" | jq -r '.tool_input.command')
DANGEROUS_PATTERNS=(
"git push"
"git reset --hard"
"git clean -fd"
"git clean -f"
"git branch -D"
"git checkout \."
"git restore \."
"push --force"
"reset --hard"
)
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if echo "$COMMAND" | grep -qE "$pattern"; then
echo "BLOCKED: '$COMMAND' matches dangerous pattern '$pattern'. The user has prevented you from doing this." >&2
exit 2
fi
done
exit 0
-98
View File
@@ -1,98 +0,0 @@
#!/bin/bash
# Blocks Bash commands that delete or destroy files.
# Companion to CLAUDE.md's "never delete files" rule.
block() {
printf 'BLOCKED: %s\n' "$1" >&2
exit 2
}
command -v jq >/dev/null 2>&1 || block "jq is required to validate Bash commands."
command -v realpath >/dev/null 2>&1 || block "realpath is required to validate deletion paths."
command -v stat >/dev/null 2>&1 || block "stat is required to validate the temporary directory."
INPUT=$(cat) || block "could not read hook input."
COMMAND=$(printf '%s' "$INPUT" | jq -er '.tool_input.command | select(type == "string")') ||
block "hook input does not contain a valid tool_input.command string."
# Do not trust TMPDIR: when it is unset, `$TMPDIR/path` expands to `/path`.
# This fixed per-user directory is the only place where deletion is permitted.
TMP_ROOT="/tmp/claude-$(id -u)"
# True only for one command in one of these exact forms:
# rm -- /tmp/claude-UID/path [...]
# rm -f|-r|-rf|-fr -- /tmp/claude-UID/path [...]
#
# Paths are deliberately restricted to an unquoted, expansion-free character
# set. The filesystem checks reject an escaping canonical path and any symlink
# that already exists in the path. This is defense against accidental deletion,
# not a race-free security boundary against a process changing paths concurrently.
confined_to_tmp() {
local cmd="$1" option_part path_part path canonical component partial mode uid
local -a paths components
uid=$(id -u) || return 1
[[ -d "$TMP_ROOT" && ! -L "$TMP_ROOT" ]] || return 1
[[ "$(realpath -e -- "$TMP_ROOT" 2>/dev/null)" == "$TMP_ROOT" ]] || return 1
[[ "$(stat -c '%u' -- "$TMP_ROOT" 2>/dev/null)" == "$uid" ]] || return 1
mode=$(stat -c '%a' -- "$TMP_ROOT" 2>/dev/null) || return 1
(( (8#$mode & 0022) == 0 )) || return 1
if [[ "$cmd" =~ ^rm\ (--|-f\ --|-r\ --|-rf\ --|-fr\ --)\ (/tmp/claude-[0-9]+/[A-Za-z0-9._/+,=:@%-]+(\ /tmp/claude-[0-9]+/[A-Za-z0-9._/+,=:@%-]+)*)$ ]]; then
option_part="${BASH_REMATCH[1]}"
path_part="${BASH_REMATCH[2]}"
else
return 1
fi
# The regex excludes shell syntax and whitespace within paths, so this split
# does not attempt to interpret arbitrary Bash source.
read -r -a paths <<< "$path_part"
((${#paths[@]} > 0)) || return 1
for path in "${paths[@]}"; do
[[ "$path" == "$TMP_ROOT"/?* ]] || return 1
[[ "$path" != */./* && "$path" != */../* && "$path" != */. && "$path" != */.. ]] || return 1
canonical=$(realpath -m -- "$path" 2>/dev/null) || return 1
[[ "$canonical" == "$TMP_ROOT"/?* ]] || return 1
# realpath catches symlink escapes. Reject in-tree symlinks too, since rm
# must not traverse a link whose target happens to currently be in the tree.
partial=""
IFS='/' read -r -a components <<< "$path"
for component in "${components[@]}"; do
[[ -n "$component" ]] || continue
partial="$partial/$component"
[[ ! -L "$partial" ]] || return 1
done
done
# Keep the variable used so shellcheck documents that only allowlisted option
# spellings can reach this point.
[[ -n "$option_part" ]]
}
confined_to_tmp "$COMMAND" && exit 0
# These patterns are a backstop for ordinary Bash spellings, not a parser.
# Include quotes and backslashes between letters to catch forms such as r""m
# and r\m that Bash resolves to rm.
DANGEROUS_PATTERNS=(
'\br["'"'"'\\]*m\b'
'\bu["'"'"'\\]*n["'"'"'\\]*l["'"'"'\\]*i["'"'"'\\]*n["'"'"'\\]*k\b'
'\br["'"'"'\\]*m["'"'"'\\]*d["'"'"'\\]*i["'"'"'\\]*r\b'
'\bshred\b'
'\btruncate\b'
'\bfind\b.*-delete\b'
'\btrash\b'
)
for pattern in "${DANGEROUS_PATTERNS[@]}"; do
if printf '%s\n' "$COMMAND" | grep -qE "$pattern"; then
block "'$COMMAND' is a deletion command. Only literal 'rm [-f|-r|-rf|-fr] -- $TMP_ROOT/path' commands without variables, quotes, globs, shell operators, or symlinks are allowed."
fi
done
exit 0
+13 -69
View File
@@ -17,44 +17,11 @@
"Bash(git push *)",
"Bash(ssh *)",
"Bash(ssh)",
"Bash(scp *)",
"Bash(shred:*)",
"Bash(truncate:*)",
"Bash(trash:*)",
"CronCreate",
"CronDelete",
"CronList",
"PushNotification",
"NotebookEdit",
"DesignSync"
"Bash(scp *)"
],
"defaultMode": "auto"
},
"model": "claude-opus-4-8",
"disableClaudeAiConnectors": true,
"hooks": {
"PostToolUse": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "atuin hook claude-code"
}
]
}
],
"PostToolUseFailure": [
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "atuin hook claude-code"
}
]
}
],
"PreToolUse": [
{
"matcher": "Bash",
@@ -62,39 +29,24 @@
{
"type": "command",
"command": "~/.claude/hooks/block-dangerous-git.sh"
},
{
"type": "command",
"command": "~/.claude/hooks/block-file-deletion.sh"
}
]
},
{
"matcher": "Bash",
"hooks": [
{
"type": "command",
"command": "atuin hook claude-code"
}
]
}
]
},
"disableWorkflows": true,
"disableArtifact": true,
"statusLine": {
"type": "command",
"command": "bash /home/tgrosinger/.claude/statusline-command.sh"
},
"enabledPlugins": {
"claude-md-management@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true,
"gopls-lsp@claude-plugins-official": true,
"pr-review-toolkit@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true,
"gopls-lsp@claude-plugins-official": true,
"frontend-design@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"skill-creator@claude-plugins-official": true,
"typescript-lsp@claude-plugins-official": true
"typescript-lsp@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true,
"security-guidance@claude-plugins-official": true
},
"sandbox": {
"enabled": true,
@@ -118,26 +70,19 @@
"filesystem": {
"allowWrite": [
"~/.local/share/pnpm",
"~/.cache/pnpm",
"~/Documents/Atrium",
"/tachi/docker/atribot/vault"
"~/.cache/pnpm"
],
"denyRead": [
"~/.ssh",
"~/.config/Signal",
"~/Documents",
"/tachi/backups",
"/tachi/documents",
"/tachi/docker"
],
"allowRead": [
"~/Documents/Atrium",
"/tachi/docker/atribot/vault"
"~/Documents"
]
},
"excludedCommands": [
"git push *",
"brew *"
"brew *",
"devbox add *",
"nix *"
]
},
"spinnerVerbs": {
@@ -146,7 +91,7 @@
"Thinking"
]
},
"effortLevel": "xhigh",
"effortLevel": "high",
"tui": "fullscreen",
"voice": {
"enabled": false,
@@ -157,7 +102,6 @@
"autoDreamEnabled": true,
"theme": "light",
"editorMode": "vim",
"remoteControlAtStartup": false,
"agentPushNotifEnabled": true,
"skipAutoPermissionPrompt": true
}
-1
View File
@@ -1 +0,0 @@
../.agents/skills
-31
View File
@@ -1,31 +0,0 @@
#!/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"
+1 -8
View File
@@ -4,8 +4,6 @@ workspaces = true
keymap_mode = "auto"
enter_accept = true
sync_address = "https://atuin.i.grosinger.net"
history_filter = [
"^cd$",
"^lg$",
@@ -20,9 +18,4 @@ history_filter = [
## The "workspace" mode is skipped when not in a workspace or workspaces = false.
## Default filter mode can be overridden with the filter_mode setting.
#filters = [ "global", "host", "session", "session-preload", "workspace", "directory" ]
filters = [ "global", "host", "workspace", "directory" ]
[tmux]
enabled = true
width = "80%"
height = "60%"
filters = [ "global", "workspace", "directory" ]
@@ -1,16 +0,0 @@
function __dev_wt_pr_numbers
# Open PRs in the current repo: number<TAB>title. gh resolves the repo from
# the remote, so this only yields anything inside a GitHub checkout.
gh pr list --json number,title --jq '.[] | "\(.number)\t\(.title)"' 2>/dev/null
end
function __dev_wt_pr_branch
# Second arg is the branch; default to the head branch of the PR already typed.
set -l tokens (commandline -opc)
test (count $tokens) -ge 2; or return
gh pr view $tokens[2] --json headRefName --jq .headRefName 2>/dev/null
end
complete -c dev-wt-pr -f -n __fish_is_first_arg -a '(__dev_wt_pr_numbers)'
complete -c dev-wt-pr -f -n 'test (count (commandline -opc)) -eq 2' -a '(__dev_wt_pr_branch)'
complete -c dev-wt-pr -n 'test (count (commandline -opc)) -eq 3' -a '(__fish_complete_directories)' -d 'Repo path'
-6
View File
@@ -1,6 +0,0 @@
# Gradle needs a JDK it can actually run on. Fedora 43's default is JDK 25,
# which Gradle 8.13 rejects with "Unsupported class file major version 69".
# $HOME is shared with the host, so scope this to the distrobox via CONTAINER_ID.
if set -q CONTAINER_ID; and test -x /usr/lib/jvm/java-21-openjdk/bin/javac
set -gx JAVA_HOME /usr/lib/jvm/java-21-openjdk
end
+1 -1
View File
@@ -34,7 +34,7 @@ function __dev_create_session -a session dir
tmux split-window -v -t "$right" -c "$dir" $shell
set -l bottom_right (tmux display-message -t "$session:dev" -p '#{pane_id}')
tmux send-keys -t "$left" 'vim' Enter
tmux send-keys -t "$right" 'dev-claude' Enter
tmux send-keys -t "$right" 'claude' Enter
tmux select-pane -t "$bottom_right"
end
+6 -10
View File
@@ -1,10 +1,6 @@
# Mise shims so non-interactive shells (scripts, editor spawns) find tools.
# Interactive sessions get exact versions from `mise activate` below.
fish_add_path --global ~/.local/bin ~/.local/share/mise/shims ~/go/bin
if test -d ~/code/yt-dlp
fish_add_path --global ~/code/yt-dlp
end
fish_add_path ~/.local/share/mise/shims
if status is-interactive
# Commands to run in interactive sessions can go here
@@ -13,12 +9,10 @@ if status is-interactive
# https://fishshell.com/docs/current/cmds/fish_greeting.html
set -g fish_greeting
if command -q brew
brew shellenv | source
else if test -x /home/linuxbrew/.linuxbrew/bin/brew
if test -f /home/linuxbrew/.linuxbrew/bin/brew
/home/linuxbrew/.linuxbrew/bin/brew shellenv | source
end
if command -q atuin
if test -f $(which atuin)
atuin init fish | source
end
@@ -30,7 +24,6 @@ if status is-interactive
alias lg="lazygit"
alias la="eza --long --header --git --group --time-style long-iso -a"
alias record='wf-recorder -g "$(slurp)"'
alias claer="clear"
# Set a location to store variables that should not be tracked in git.
if test -f ~/.local/fish_env.fish
@@ -41,6 +34,9 @@ if status is-interactive
# https://fishshell.com/docs/current/cmds/abbr.html
abbr --add --position command pc process-compose
# Set Path
fish_add_path -p /home/tgrosinger/.dotfiles/bin/linux
# Per-directory toolchains and global tools.
# https://mise.jdx.dev/getting-started.html
if type -q mise
+1
View File
@@ -1,3 +1,4 @@
# This file contains fish universal variable definitions.
# VERSION: 3.0
SETUVAR __fish_initialized:4300
SETUVAR fish_user_paths:/home/tgrosinger/\x2elocal/bin\x1e/home/tgrosinger/\x2elocal/share/mise/shims\x1e/home/tgrosinger/go/bin\x1e/home/tgrosinger/\x2edotfiles/bin/linux
-29
View File
@@ -1,29 +0,0 @@
function aiq -d "Ask Codex for a fish shell command"
if test (count $argv) -eq 0
echo "Usage: aiq <what you want to do>" >&2
return 1
end
if not command -q codex
echo "aiq: codex is not installed or is not in PATH" >&2
return 127
end
set -l need (string join ' ' -- $argv)
set -l prompt "You are suggesting a shell command for the fish shell. Do not run the command or modify any files. The user needs to: $need
Output the suggested command first. Follow it with additional details only when they are very important, or when there is relevant optional configuration the user should know about. Keep the response concise and do not wrap the command in backticks."
# Codex writes progress and run metadata to stderr; keep this helper focused
# on the final answer, but surface stderr when the request fails.
set -l errors (mktemp); or return 1
command codex exec --ephemeral --sandbox read-only --skip-git-repo-check "$prompt" 2>$errors
set -l result $status
if test $result -ne 0
command cat $errors >&2
end
command rm -f $errors
return $result
end
-6
View File
@@ -1,6 +0,0 @@
function del-wt -d "Remove current git worktree and cd into the main worktree"
set -l main_wt (git worktree list --porcelain | head -1 | string replace "worktree " "")
set -l current (git rev-parse --show-toplevel 2>/dev/null)
cd "$main_wt"; or return 1
git worktree remove "$current" # git handles dirty/main-worktree guards itself
end
@@ -1,56 +0,0 @@
function dev-claude -d "Start Claude with a durable session id so tmux-resurrect can restore the conversation"
# tmux-resurrect saves each pane's command line verbatim and replays it on
# restore. Pinning the session id at launch is what lets a restored pane
# rejoin its exact conversation; cwd alone can't disambiguate, since several
# Claude sessions often share one worktree.
# Run Claude inside a transient scope in claude.slice so a runaway subprocess
# tree hits that slice's memory cap and is killed there, instead of thrashing
# the whole machine into a lockup. See ~/.dotfiles/docs/claude-resource-limits.md
#
# The /usr/bin/env shim is load-bearing, in two ways:
# 1. systemd-run resolves its target through PATH and rewrites argv[0] to an
# absolute path. tmux-resurrect matches panes with an anchored "^claude "
# regex, so without the shim the saved command becomes an absolute path,
# stops matching, and pane restore silently dies. env re-execs with argv[0]
# as a bare "claude", keeping the saved command byte-identical.
# 2. env does the PATH lookup that "command" would do here, so a fish
# function or alias named claude still can't shadow the real binary.
set -l launch /usr/bin/env claude
if command -q systemd-run
set launch systemd-run --user --scope --quiet --collect --slice=claude.slice -- /usr/bin/env claude
end
# Never exec here. tmux-resurrect's ps save strategy reads the *children* of
# the pane pid (`ps -ao ppid,args | grep "^$pane_pid"`), so exec'ing would make
# claude the pane pid itself and the saved command would be one of claude's own
# subprocesses — restore then silently does nothing. Keeping fish as the pane
# process is what puts "claude --session-id <uuid>" where resurrect looks.
# Claude exiting drops back to a prompt rather than closing the pane.
# Restore path: resurrect replays the id we launched with. Claude rejects
# --session-id for an id that already exists, so switch to --resume once a
# transcript for it is on disk.
if test (count $argv) -ge 2
and contains -- $argv[1] --session-id --resume
and string match -qir '^[0-9a-f]{8}(-[0-9a-f]{4}){3}-[0-9a-f]{12}$' -- $argv[2]
set -l id $argv[2]
set -l transcript (find $HOME/.claude/projects -maxdepth 2 -name "$id.jsonl" -print -quit)
if test -n "$transcript"
$launch --resume $id $argv[3..]
return
end
$launch --session-id $id $argv[3..]
return
end
# An explicit session flag means the caller is choosing the conversation.
for flag in -c --continue -r --resume --session-id
if contains -- $flag $argv
$launch $argv
return
end
end
$launch --session-id (uuidgen) $argv
end
+2 -8
View File
@@ -1,6 +1,6 @@
function dev-wt-pr -d "Open a GitHub PR in a worktree in a new tmux window (Octo review + Claude)" -a pr_number branch repo_path
if test -z "$pr_number"
echo "Usage: dev-wt-pr <pr-number> [branch] [repo-path]"
echo "Usage: dev-wt-pr <pr-number> <branch> [repo-path]"
return 1
end
@@ -22,12 +22,6 @@ function dev-wt-pr -d "Open a GitHub PR in a worktree in a new tmux window (Octo
return 1
end
# gh-dash passes the branch to save a lookup; manual callers can omit it and
# we resolve it from the PR number here.
if test -z "$branch"
set branch (gh pr view $pr_number --json headRefName --jq .headRefName 2>/dev/null)
end
set -l main_wt_path (git worktree list --porcelain | head -1 | string replace "worktree " "")
set -l parent_dir (dirname "$main_wt_path")
# Key the worktree on the PR number to stay unique and avoid slashes in
@@ -70,7 +64,7 @@ function dev-wt-pr -d "Open a GitHub PR in a worktree in a new tmux window (Octo
tmux send-keys -t "$win" 'nvim -c "Octo review start"' Enter
set -l right (tmux split-window -h -P -F '#{pane_id}' -t "$win" -c "$wt_path" $shell)
tmux send-keys -t "$right" 'dev-claude' Enter
tmux send-keys -t "$right" 'claude' Enter
tmux select-window -t "$win"
end
+1 -8
View File
@@ -6,7 +6,6 @@ function fish_prompt
set -l red (set_color -o red)
set -l green (set_color -o green)
set -l blue (set_color -o blue)
set -l magenta (set_color -o magenta)
set -l normal (set_color normal)
set -l arrow_color "$green"
@@ -21,11 +20,5 @@ function fish_prompt
set -l cwd $cyan(prompt_pwd | path basename)
# distrobox exports CONTAINER_ID inside the container
set -l box ""
if set -q CONTAINER_ID
set box $magenta"📦$CONTAINER_ID "
end
echo -n -s $arrow ' ' $box $cwd $repo_info $normal ' '
echo -n -s $arrow ' '$cwd $repo_info $normal ' '
end
+3 -3
View File
@@ -7,9 +7,9 @@ promptToReturnFromSubprocess: false # removes "press enter to return to lazygit"
notARepository: 'skip'
git:
autoForwardBranches: "none"
diffRenderers:
- command: delta --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"
- command: delta --paging=never --line-numbers --no-gitconfig --light
pagers:
- pager: delta --paging=never --line-numbers --hyperlinks --hyperlinks-file-link-format="lazygit-edit://{path}:{line}"
- pager: delta --paging=never --line-numbers --no-gitconfig --light
os:
editPreset: "nvim"
gui:
+9 -23
View File
@@ -13,27 +13,13 @@ disable_backends = ["asdf", "ubi"]
minimum_release_age = "14d"
[tools]
# Short names resolve to the aqua backend (checksum + signature verified)
"aqua:F1bonacc1/process-compose" = "1.120.0"
"aqua:neovim/neovim" = "0.12.4"
node = "lts"
pnpm = "latest"
"npm:@anthropic-ai/sandbox-runtime" = "latest"
"npm:typescript-language-server" = "latest"
"npm:typescript" = "latest"
"github:Satty-org/Satty" = "0.20.1"
"npm:@anthropic-ai/sandbox-runtime" = "0.0.67"
"npm:@earendil-works/pi-coding-agent" = "0.83.0"
"npm:typescript" = "7.0.2"
"npm:typescript-language-server" = "5.3.0"
bat = "0.26.1"
btop = "1.4.7"
delta = "0.19.2"
dive = "0.13.1"
eza = "0.23.5"
fd = "10.4.2"
gh = "2.97.0"
jq = "1.8.2"
lazygit = "0.64.1"
node = "24.18.1"
opencode = "1.15.11"
pnpm = "11.18.0"
restic = "0.19.1"
ripgrep = "15.2.0"
tmux = "3.7b"
uv = "0.12.0"
"github:DarthSim/overmind" = "latest"
"github:F1bonacc1/process-compose" = "latest"
"github:modem-dev/hunk" = "latest"
"npm:@earendil-works/pi-coding-agent" = "latest"
+19 -20
View File
@@ -1,39 +1,38 @@
{
"LazyVim": { "branch": "main", "commit": "c10948c50b18fae7f256433afdef09e432410480" },
"SchemaStore.nvim": { "branch": "main", "commit": "d34c58439271f5e73ed79c2b8d1a09731c4525f1" },
"SchemaStore.nvim": { "branch": "main", "commit": "6ff1f21b2e2b77ec59f7433ce2d9fbc052d908ac" },
"blink.cmp": { "branch": "main", "commit": "78336bc89ee5365633bcf754d93df01678b5c08f" },
"bufferline.nvim": { "branch": "main", "commit": "655133c3b4c3e5e05ec549b9f8cc2894ac6f51b3" },
"catppuccin": { "branch": "main", "commit": "edefef779ab08ce1a4a404713e3012b0d202bd35" },
"claude-review.nvim": { "branch": "main", "commit": "899a33a04d26b6a75384dda176e212eee75a2532" },
"conform.nvim": { "branch": "master", "commit": "016802de402556da54c36bd7359b441266b01cdd" },
"diffview.nvim": { "branch": "main", "commit": "43e60bca414e4991ed10118e59f809fb03bbeddd" },
"flash.nvim": { "branch": "main", "commit": "5f0f270fdc7c5b0c21d903ee85b9cb06f2ac636a" },
"catppuccin": { "branch": "main", "commit": "e068ab5f8261f23f6f71ffd8791ae40315b77b9c" },
"conform.nvim": { "branch": "master", "commit": "619363c30309d29ffa631e67c8183f2a72caa373" },
"diffview.nvim": { "branch": "main", "commit": "bcf4b62b4acc36a7c3d19e423713a220c838a668" },
"flash.nvim": { "branch": "main", "commit": "fcea7ff883235d9024dc41e638f164a450c14ca2" },
"friendly-snippets": { "branch": "main", "commit": "6cd7280adead7f586db6fccbd15d2cac7e2188b9" },
"gitsigns.nvim": { "branch": "main", "commit": "42d6aed4e94e0f0bbced16bbdcc42f57673bd75e" },
"grug-far.nvim": { "branch": "main", "commit": "11595bf747edc270bce2069d1020502ad4ae56cf" },
"grug-far.nvim": { "branch": "main", "commit": "c69859c1d5427ab5fc7ed12380ab521b4e336691" },
"lazy.nvim": { "branch": "main", "commit": "85c7ff3711b730b4030d03144f6db6375044ae82" },
"lazydev.nvim": { "branch": "main", "commit": "ff2cbcba459b637ec3fd165a2be59b7bbaeedf0d" },
"lualine.nvim": { "branch": "master", "commit": "221ce6b2d999187044529f49da6554a92f740a96" },
"markdown-preview.nvim": { "branch": "master", "commit": "a923f5fc5ba36a3b17e289dc35dc17f66d0548ee" },
"mason-lspconfig.nvim": { "branch": "main", "commit": "40276c4df7e6bdce6801d6c035c6227f9115a855" },
"mason-lspconfig.nvim": { "branch": "main", "commit": "47059d71b42d74b0a1e9f61c1d99d301039c3b5b" },
"mason.nvim": { "branch": "main", "commit": "2a6940af80375532e5e9e7c1f2fc6319a1b7a69d" },
"mini.ai": { "branch": "main", "commit": "25248c6aa002391936a6200f12d1466015987133" },
"mini.diff": { "branch": "main", "commit": "626b8a5b93874c4d05ca25aedec56cfff0b378fb" },
"mini.icons": { "branch": "main", "commit": "98faae31e9be1cc054ae63485e58ceb185efcad0" },
"mini.pairs": { "branch": "main", "commit": "b1c5a726921b7a8c9321e9a7a208aa0571de5810" },
"mini.surround": { "branch": "main", "commit": "8d5d0c5aa92449368ac251e85451d79d8f69d296" },
"mini.ai": { "branch": "main", "commit": "cb20f298ebf5ae91924cd0c6c310712de2ef4086" },
"mini.diff": { "branch": "main", "commit": "0743d26bd858ebe32efcf5c86a91a422a000f273" },
"mini.icons": { "branch": "main", "commit": "24dbea2195c477e57d581215839a6ab915f34b14" },
"mini.pairs": { "branch": "main", "commit": "fd150ac39b78e6a2286f5138e472b7dc7eba43b9" },
"mini.surround": { "branch": "main", "commit": "a2f644f3759edd3d3f8b6a6d55378408bfe6d290" },
"noice.nvim": { "branch": "main", "commit": "7bfd942445fb63089b59f97ca487d605e715f155" },
"nui.nvim": { "branch": "main", "commit": "10fc361835c856ba4233ef5ea135b919bf3dce97" },
"nui.nvim": { "branch": "main", "commit": "de740991c12411b663994b2860f1a4fd0937c130" },
"nvim-ansible": { "branch": "main", "commit": "c7f595d568b588942d4d0c37b5cd6cae3764a148" },
"nvim-lint": { "branch": "master", "commit": "3d55c8f67c6ae5c15e1042571e107c7a3d5c5f4e" },
"nvim-lspconfig": { "branch": "master", "commit": "16286347bdba1333c7d124d9de9fe6630731b2b2" },
"nvim-treesitter": { "branch": "main", "commit": "19071296d3d643b48615ee574a20e8a03ac40872" },
"nvim-treesitter-textobjects": { "branch": "main", "commit": "898ee307df58f854d11cd7edd06472574d48014e" },
"nvim-lint": { "branch": "master", "commit": "a219b2c9e5b4765e5c845aba119dad55806fcaf1" },
"nvim-lspconfig": { "branch": "master", "commit": "292f44408498103c47996ff5c18fd366293840d8" },
"nvim-treesitter": { "branch": "main", "commit": "4916d6592ede8c07973490d9322f187e07dfefac" },
"nvim-treesitter-textobjects": { "branch": "main", "commit": "851e865342e5a4cb1ae23d31caf6e991e1c99f1e" },
"nvim-ts-autotag": { "branch": "main", "commit": "88c1453db4ba7dd24131086fe51fdf74e587d275" },
"octo.nvim": { "branch": "master", "commit": "af2411604b51cb4a0f3e2de50b1b7cacc2581c48" },
"octo.nvim": { "branch": "master", "commit": "b9a73e167f851a98d8f29d62658d3640bb8a7314" },
"persistence.nvim": { "branch": "main", "commit": "b20b2a7887bd39c1a356980b45e03250f3dce49c" },
"plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" },
"render-markdown.nvim": { "branch": "main", "commit": "4663eb3ecd538bd5062628fb6d95bbe6bdca78f6" },
"render-markdown.nvim": { "branch": "main", "commit": "f422cb5c6855f150e2ddcfaf44e7157b98b34f6a" },
"sidekick.nvim": { "branch": "main", "commit": "208e1c5b8170c01fd1d07df0139322a76479b235" },
"snacks.nvim": { "branch": "main", "commit": "882c996cf28183f4d63640de0b4c02ec886d01f2" },
"todo-comments.nvim": { "branch": "main", "commit": "31e3c38ce9b29781e4422fc0322eb0a21f4e8668" },
@@ -8,7 +8,7 @@ return {
},
},
{
url = "ssh://git@git.grosinger.net:22322/tgrosinger/claude-review.nvim.git",
dir = "/home/tgrosinger/code/claude-review",
cmd = "ClaudeReview",
dependencies = {
"dlyongemallo/diffview.nvim",
@@ -1,12 +1,5 @@
-- Disabled: broke git worktrees. On launch with no file arg, the VimEnter
-- autoload restored a session whose buffers belonged to another worktree (the
-- crash-save autocmd had written them in). The stale buffer became current, and
-- since snacks-explorer, LazyVim.root(), and diffview are all current-buffer
-- relative, they all followed it to the wrong worktree/branch even though :pwd
-- was correct. Re-enable only with per-worktree session isolation.
return {
"folke/persistence.nvim",
enabled = false,
init = function()
vim.api.nvim_create_autocmd("VimEnter", {
group = vim.api.nvim_create_augroup("PersistenceAutoload", { clear = true }),
+1 -1
View File
@@ -11,7 +11,7 @@ set $down j
set $up k
set $right l
# Your preferred terminal emulator
set $term alacritty
set $term ghostty
# Your preferred application launcher
# Note: pass the final command to swaymsg so that the resulting window can be opened
# on the original workspace that the command was run on.
-38
View File
@@ -1,38 +0,0 @@
# Resource ceiling for Claude Code sessions, applied via dev-claude.fish.
#
# Why: a runaway subprocess tree under Claude (parallel vitest/tsc/eslint) could
# exhaust RAM and drive the machine into zram thrash, which locked up the desktop
# hard enough that rebooting was the only recovery. Capping the tree here means it
# gets OOM-killed inside this cgroup while sway and everything else stay alive.
#
# Limits are on the SLICE, not on each scope, so all concurrent sessions share one
# budget. Per-scope limits would let four sessions claim 4x the ceiling and defeat
# the point.
#
# Full write-up: ~/.dotfiles/docs/claude-resource-limits.md
[Unit]
Description=Claude Code sessions (resource-capped)
[Slice]
# Soft ceiling: throttle and reclaim first, so there is back pressure before
# anything is killed.
MemoryHigh=10G
# Hard ceiling: OOM-kill happens inside this cgroup. Leaves ~16G of the 30G total
# for the rest of the system, which normally sits around 13G.
MemoryMax=14G
# Swap is 8G of zram ONLY. Uncapped, this slice can push pages into zram, which
# itself occupies physical RAM -- that feedback loop is the thrash spiral. Keep
# Claude out of most of the pool.
MemorySwapMax=2G
# Only bites under contention, so nothing is slowed down when the box is idle.
CPUWeight=50
# Reserve roughly 4 of 16 cores for sway and the compositor unconditionally.
CPUQuota=1200%
# Bound pid exhaustion from a runaway worker pool.
TasksMax=2048
+7 -4
View File
@@ -36,10 +36,10 @@
sync = "!git fetch -p && git rebase origin/$(git default-branch)"
[core]
editor = nvim
editor = /home/linuxbrew/.linuxbrew/bin/nvim
pager = delta
attributesfile = ~/.gitattributes
excludesfile = ~/.gitignore_global
attributesfile = /home/tgrosinger/.gitattributes
excludesfile = /home/tgrosinger/.gitignore_global
[help]
autocorrect = prompt
@@ -58,7 +58,7 @@
renames = true
[include]
path = ~/.config/delta/themes/catppuccin.gitconfig
path = /home/tgrosinger/.config/delta/themes/catppuccin.gitconfig
[delta]
# Does not behave well for comments
@@ -90,6 +90,9 @@
[status]
submoduleSummary = true
[url "ssh://git@gitlab.i.extrahop.com/"]
insteadOf = https://gitlab.i.extrahop.com/
[merge]
conflictstyle = zdiff3
-11
View File
@@ -1,11 +0,0 @@
.github/instructions
.plans
.scratch
.magpie
**/.claude/settings.local.json
**/.claude/.cc-writes/
CLAUDE.local.md
.worktree-setup.sh
-1
View File
@@ -1 +0,0 @@
../lib/dev-tickets/bin/ticket.mjs
@@ -1,21 +0,0 @@
#!/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
@@ -1,227 +0,0 @@
{
"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
@@ -1,16 +0,0 @@
{
"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
@@ -1,86 +0,0 @@
// 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
@@ -1,374 +0,0 @@
// 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 }));
});
}
@@ -1,42 +0,0 @@
// 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
@@ -1,120 +0,0 @@
// 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 };
@@ -1,22 +0,0 @@
// 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' });
@@ -1,154 +0,0 @@
// 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
@@ -1,21 +0,0 @@
// 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
@@ -1,317 +0,0 @@
// 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));
}
}
@@ -1,121 +0,0 @@
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`);
});
@@ -1,138 +0,0 @@
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);
});
@@ -1,243 +0,0 @@
---
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.
@@ -1,8 +0,0 @@
spec_version: "0.2.0"
name: "TaskNotes"
description: "Task collection managed by TaskNotes for Obsidian"
settings:
types_folder: "_types"
default_strict: false
exclude:
- "_types"
@@ -1,86 +0,0 @@
// 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));
}
}
@@ -1,165 +0,0 @@
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'));
});

Some files were not shown because too many files have changed in this diff Show More