Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 14 additions & 9 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Agent guide
# agent guide

This machine has `shepherd`, a CLI todo board backed by a markdown file. Manage
the user's todos through it — never hand-edit the todo file; the binary owns the
Expand All @@ -12,14 +12,14 @@ format.
- `shepherd stats [--json] [--all] [--legend]` — board metrics (charts, or `--json` numbers; `--legend` explains each chart)
- `shepherd add "buy milk @home !h due:tomorrow" [--json]` — add an item
- `shepherd sub <ref> "<text>" [--json]` — add a subtask to an item (same quick-add tokens)
- `shepherd edit <ref> "<tokens>" [--json]` — merge tokens onto an item (or subtask); only the given fields change. Tokens: `@category`, `!prio`, `due:`, `defer:`, `link:`, `status:`, `note:`, and text. A bare key clears its field; `note:` takes the rest of the line
- `shepherd list --filter <q>` — list only matching items (text/note/category/due/defer/link), keeping their real indexes for done/rm
- `shepherd edit <ref> "<tokens>" [--json]` — merge tokens onto an item (or subtask); only the given fields change. Tokens: `@category`, `#tag`/`tags:a,b`, `!prio`, `due:`, `defer:`, `link:`, `status:`, `note:`, and text. A bare key clears its field; `note:` takes the rest of the line
- `shepherd list --filter <q>` — list only matching items (text/note/category/tags/due/defer/link), keeping their real indexes for done/rm
- `shepherd done <ref>... [--json]` / `shepherd undone <ref>...` — (un)complete one or more items/subtasks
- `shepherd archive <ref>... [--json]` — move whole items off the live board into the sibling `archive.md` (per-item counterpart to whole-board `board archive`); subtasks can't be archived alone
- `shepherd rm <ref>... [--dry-run] [--json]` — remove one or more items/subtasks (`--dry-run`/`-n` previews without writing)

`edit` is the single setter for every field — status, note, category, priority,
due, defer, link, and text all change through its tokens (`edit 2 "status:in-progress"`,
`edit` is the single setter for every field — status, note, category, tags,
priority, due, defer, link, and text all change through its tokens (`edit 2 "status:in-progress"`,
`edit 2 "note:call the vendor"`); a bare `key:` clears. `done`/`undone` are the
only shorthands, for the terminal state.

Expand All @@ -34,12 +34,17 @@ never lose one another's writes. `--json` on any mutating verb echoes the
resulting item(s) like `list --json` and reports failures as `{"error":…}` on
stdout.

Quick-add tokens (shared by `add`, `sub`, `edit`): `@category`, `!h`/`!m`/`!l`
priority, `due:<today|tomorrow|+3d|15-07-2026>`, `defer:<same date forms>`
(start/defer date), `link:<url>`, `status:<name>`,
Quick-add tokens (shared by `add`, `sub`, `edit`): `@category`, `#tag`,
`tags:<a,b>`, `!h`/`!m`/`!l` priority, `due:<today|tomorrow|+3d|15-07-2026>`,
`defer:<same date forms>` (start/defer date), `link:<url>`, `status:<name>`,
and `note:<text>` (holds spaces, takes the rest of the line — put it last).
`list --json` reports `id` (the stable handle), `completed` (done timestamp),
`defer`, `link`, and `status` per item.
`defer`, `link`, `tags`, and `status` per item.

Category and tags are different axes: `category` is one lowercase label per item
and drives board grouping/order; `tags` is a free-form lowercase set with no
ordering effect. `#tag` only adds, so replace the whole set with
`tags:<a,b>` (or clear it with a bare `tags:`) to drop one.

Subtasks nest one level under an item. `list --json` puts them in each item's
`subtasks` array (1-based within the parent); address them by id or as `n.m`. Completion
Expand Down
4 changes: 2 additions & 2 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
# Contributing
# contributing

Go plugin, split into `internal/` packages (`cli`, `store`, `todo`, `tui`). No frameworks.

Expand All @@ -15,7 +15,7 @@ meta-linter; install it from https://golangci-lint.run. CI
(`.github/workflows/pull-request.yml`) runs all of the above on every pull
request; keep them green.

## Git hooks
## git hooks

Enable the pre-commit hook (gofmt's staged Go files, once per clone):

Expand Down
63 changes: 41 additions & 22 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,20 +73,21 @@ herdr plugin install jwarykowski/shepherd
| `tab` | cycle status (open → in-progress → done → open); see `statuses` config |
| `h` / `m` / `l` | set priority high / medium / low |
| `g` | set category |
| `T` | set tags (space- or comma-separated; empty clears) |
| `t` | set due date — `today`, `tomorrow`, `3d`/`2w`/`5m`/`1y`, or `DD-MM-YYYY` (empty clears) |
| `s` | set defer/start date (same formats as due; item shows dimmed with `starts Nd` until then) |
| `L` | set a reference link (url) |
| `o` | open the selected item's link in the browser |
| `a` | add item (inline syntax below) |
| `a` | add item (inline syntax below); inherits the selected item's category unless the text names one |
| `S` | add a subtask to the selected item |
| `u` | edit item (or subtask) text |
| `d` | open detail view (shows every field) |
| `v` | cycle view: category / priority / table |
| `v` | cycle view: category / priority / tag / table |
| `F` | hide / show the footer help grid (the `jwarykowski/shepherd` · version line stays); `hidefooter` config sets the default |
| `A` | toggle the [global view](#global-view) across all boards |
| `b` | open the board picker — every board with done/total counts; `enter` jumps, `a` creates a board, `r` renames, `A` archives, `x` deletes (confirmed), `d` shows detail (name, dir, paths, counts) for the selected board (rename/archive/delete don't apply to the default board); `e` toggles the archived-boards view where `u` unarchives the selected board |
| `e` | browse the archive (read-only; all boards in the global view; `esc` to leave) |
| `/` | filter (text/note/category/due/defer/link — also greps `archive.md`) |
| `/` | filter (text/note/category/tags/due/defer/link — also greps `archive.md`) |
| `U` / `ctrl+r` | undo / redo (multi-level) |
| `w` | save now (the header shows `● unsaved` / `● saved`) |
| `ctrl+e` | open the markdown file in `$EDITOR` |
Expand All @@ -97,17 +98,30 @@ herdr plugin install jwarykowski/shepherd
| `?` | full help page |
| `q` | save + quit |

In the detail view: `e` edit note · `space` toggle · `o` open link · `d`/`esc`/`q` back.
The detail view is also an editor: the same field keys work there — `u` text,
`h`/`m`/`l` priority, `g` category, `T` tags, `t` due, `s` defer, `L` link, `tab`
status — and saving returns you to the detail view rather than the list. Plus
`n` edit note · `space` toggle · `o` open link · `esc`/`q` back. Its footer is
the same labelled key grid as the board's, narrowed to the keys that act on the
one item (`fields` · `dates` · `item` · `go`).

**Rows carry two flush-right values**: the subtask progress (`1/2`) when the item
has subtasks, else the due/defer label — then, pinned far right, whichever
grouping axis the headers *don't* already name. The category view shows the
priority there, the priority view shows the category, and the tag view shows the
priority. Status needs no width at all: the box shape carries it (`○` open,
`◐` a named status, `✓` done).

**Inline quick-add** — `a`, then one line:
`deploy api @work !h due:tomorrow defer:1w link:https://…`. `@word` sets
category, `!h`/`!m`/`!l` priority, `due:<preset>` the due date, `defer:<preset>`
a start/defer date, `link:<url>` a reference, `status:<name>` a status, and
`note:<text>` a note (holds spaces, takes the rest of the line — put it last);
everything else is the task text.
`deploy api @work #api !h due:tomorrow defer:1w link:https://…`. `@word` sets
category, `#word` adds a tag (`tags:a,b` sets the whole set), `!h`/`!m`/`!l`
priority, `due:<preset>` the due date, `defer:<preset>` a start/defer date,
`link:<url>` a reference, `status:<name>` a status, and `note:<text>` a note
(holds spaces, takes the rest of the line — put it last); everything else is the
task text.

Items are ordered by **category, then priority, then soonest due**, grouped
under headers, with a coloured priority label flush right. **Overdue** open
under headers. **Overdue** open
items are pinned to a `⚠ overdue` group at the top. New items get a `created`
timestamp; due items show a relative label
(`due 3d`, `overdue 2d` in red). Edits save on quit, autosave after a short
Expand Down Expand Up @@ -137,7 +151,7 @@ shows a `done/total` badge:
toggle, `tab` status, `u` text, `h`/`m`/`l` priority, `t` due, `s` defer,
`L` link, `o` open link, `x` delete. Overdue/defer labels show on the row.
- `d` opens the subtask's detail view — its own fields plus a `parent` line
naming the task it belongs to; edit its note there with `e`, same as a parent.
naming the task it belongs to; edit its note there with `n`, same as a parent.
- `g` (category) is the one exception — it's parent-only, since a subtask shares
its parent's board; it's dimmed in the footer on a subtask row. Set a
subtask's fields at creation from the CLI too:
Expand Down Expand Up @@ -192,7 +206,7 @@ view. Launch with `--all`, or press `A` from any board to toggle in and out
shepherd --all # aggregate of every board
```

`v` cycles four groupings: **board → category → priority → table**. In the
`v` cycles five groupings: **board → category → priority → tag → table**. In the
board grouping each board is a header; in the others every row carries a
`[board]` tag (the table gets a `board` column). It's read-only by design —
editing stays on the focused board, so the aggregate is never written back.
Expand All @@ -203,16 +217,17 @@ The command API mirrors it: `shepherd list --all` (see [command api](#command-ap
## launch filter

`--board` gives a board its own file; `--filter` is a saved *view* over one
board — start it pre-filtered by text/note/category/due/defer/link:
board — start it pre-filtered by text/note/category/tags/due/defer/link:

```sh
./bin/shepherd --filter work # or: SHEPHERD_FILTER=work ./bin/shepherd
```

When the filter names a category (one you've configured or already use), items
you add while it's active inherit that category — so a task added on a
`--filter work` board lands in `work` and stays in view. An inline `@category`
still overrides; a filter that isn't a category leaves new items uncategorized.
`--filter work` board lands in `work` and stays in view. This is the fallback:
the selected item's own category is inherited first, and an inline `@category`
overrides both. A filter that isn't a category leaves new items uncategorized.
The two combine: `shepherd --board web --filter '!h'`.

`shepherd --version` prints the version and exits.
Expand Down Expand Up @@ -254,6 +269,7 @@ shepherd done 2f3a…c1 --json # mark by id; echo the result as JSON
shepherd undone 2.1 # reopen subtask 1 (also reopens the parent)
shepherd edit 2 "status:in-progress" # set item 2's status (status:done|open recognised)
shepherd edit 2 "note:waiting on infra" # set item 2's note (edit 2 "note:" clears it)
shepherd edit 2 "#api #docs" # add tags (tags:a,b replaces the set, tags: clears)
shepherd rm 2 # remove item 2 (rm 2.1 removes just the subtask)
shepherd rm 2 5 --dry-run # preview removing several without writing
shepherd archive 2 # move item 2 off the board into archive.md (whole items only)
Expand All @@ -278,12 +294,12 @@ cascade rules. `--json` on any mutating verb echoes the resulting item(s) in the
`list --json` shape and reports failures as `{"error":…}` on stdout.

`edit <n[.m]> "<tokens>"` sets only the fields its tokens carry — `@category`,
`!h`/`!m`/`!l`, `due:`, `defer:`, `link:`, `status:`, `note:` — and replaces the
`#tag`/`tags:`, `!h`/`!m`/`!l`, `due:`, `defer:`, `link:`, `status:`, `note:` — and replaces the
text only when plain words are present. A bare key clears its field
(`edit 2 "@ due:"`); `note:` holds spaces and takes the rest of the line, so put
it last (`edit 2 "!h note:call the bank"`).

`list --filter <q>` matches text/note/category/due/defer/link and keeps each
`list --filter <q>` matches text/note/category/tags/due/defer/link and keeps each
item's real board index, so `done`/`rm` on a filtered listing still hit the
right item.

Expand All @@ -302,9 +318,10 @@ shepherd add "ship v2 @work !h" --board web
shepherd list --board web
```

`add` accepts the same quick-add tokens as the board: `@category`, `!h`/`!m`/`!l`
priority, `due:<today|tomorrow|+3d|15-07-2026>`, `defer:`, `link:`, `status:`,
and `note:` (takes the rest of the line). Agents should read with
`add` accepts the same quick-add tokens as the board: `@category`, `#tag`
(`tags:a,b` replaces the set), `!h`/`!m`/`!l` priority,
`due:<today|tomorrow|+3d|15-07-2026>`, `defer:`, `link:`, `status:`, and `note:`
(takes the rest of the line). Agents should read with
`list --json` (stable machine shape) and mutate with `add`/`edit`/`done`/`rm`;
an open board picks up the change within ~2s. `edit` is the single setter for
every field, including `status:` (any name, like a free-form `@category`;
Expand All @@ -322,7 +339,8 @@ the archive. `--all` aggregates every board and adds a by-board breakdown;
```json
[
{ "id": "019f7390…d901", "index": 1, "done": false, "priority": "H",
"text": "buy milk", "category": "home", "created": "10-07-2026 13:40",
"text": "buy milk", "category": "home", "tags": ["errand"],
"created": "10-07-2026 13:40",
"defer": "2026-07-11", "due": "2026-07-15", "link": "https://…",
"note": "", "completed": "" }
]
Expand Down Expand Up @@ -353,7 +371,7 @@ Optional `config.toml` at `$XDG_CONFIG_HOME/shepherd/config.toml` (defaults to
`SHEPHERD_CONFIG`):

```toml
view = "category" # category (default) | priority | table
view = "category" # category (default) | priority | tag | table
density = "compact" # compact (default) | comfort
autosave = 60 # seconds idle before writing; 0 disables
categories = ["work", "home", "personal"] # tab-cycles in the category prompt
Expand Down Expand Up @@ -391,6 +409,7 @@ sub-lines:
defer: 2026-07-11
due: 2026-07-15
category: work
tags: api, docs
status: in-progress
link: https://github.com/org/repo/pull/1
note: block on the migration first
Expand Down
2 changes: 1 addition & 1 deletion herdr-plugin.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
id = "jwarykowski.herdr-shepherd"
name = "Shepherd"
version = "0.18.0"
version = "0.19.0"
min_herdr_version = "0.7.0"
description = "Shepherd — your todos herded. Interactive todo board in a split, tab, overlay, or zoomed pane. Category/priority/due ordering, overdue pinning, notes, due dates, undo/redo, filter, archive. Backed by a markdown file."
platforms = ["linux", "macos"]
Expand Down
38 changes: 9 additions & 29 deletions internal/cli/charts.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package cli

import (
"fmt"
"sort"
"strings"
"time"

Expand All @@ -10,6 +11,7 @@ import (
"github.com/NimbleMarkets/ntcharts/linechart/timeserieslinechart"
"github.com/NimbleMarkets/ntcharts/sparkline"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)

// Priority colors mirror tui/view.go: H red, M yellow, L faint. Kept here (not
Expand Down Expand Up @@ -189,7 +191,7 @@ func hbar(rows []barRow, width int) string {
labelW := 0
labels := make([]string, len(rows))
for i, r := range rows {
labels[i] = clip(r.label, maxLabel)
labels[i] = ansi.Truncate(r.label, maxLabel, "…")
if w := lipgloss.Width(labels[i]); w > labelW {
labelW = w
}
Expand All @@ -210,31 +212,11 @@ func hbar(rows []barRow, width int) string {
}
bar := lipgloss.NewStyle().Foreground(lipgloss.Color(r.color)).Render(strings.Repeat("━", fill)) +
faintStyle.Render(strings.Repeat("┄", barW-fill))
lines[i] = fmt.Sprintf("%s %s %*d", padR(labels[i], labelW), bar, valW, r.value)
lines[i] = fmt.Sprintf("%s %s %*d", lipgloss.NewStyle().Width(labelW).Render(labels[i]), bar, valW, r.value)
}
return strings.Join(lines, "\n")
}

// clip shortens a label to n display columns, ending with … when cut.
func clip(s string, n int) string {
if lipgloss.Width(s) <= n {
return s
}
r := []rune(s)
for len(r) > 0 && lipgloss.Width(string(r))+1 > n {
r = r[:len(r)-1]
}
return string(r) + "…"
}

// padR right-pads s with spaces to n display columns.
func padR(s string, n int) string {
if p := n - lipgloss.Width(s); p > 0 {
return s + strings.Repeat(" ", p)
}
return s
}

func spark(vals []int, width int) string {
sl := sparkline.New(width, 1)
f := make([]float64, len(vals))
Expand Down Expand Up @@ -285,14 +267,12 @@ func boardBars(s todo.Stats, width int) string {
names = append(names, n)
}
// stable order: most open first, name tiebreak
for i := 0; i < len(names); i++ {
for j := i + 1; j < len(names); j++ {
a, b := s.ByBoard[names[i]], s.ByBoard[names[j]]
if b.Open > a.Open || (b.Open == a.Open && names[j] < names[i]) {
names[i], names[j] = names[j], names[i]
}
sort.Slice(names, func(i, j int) bool {
if a, b := s.ByBoard[names[i]].Open, s.ByBoard[names[j]].Open; a != b {
return a > b
}
}
return names[i] < names[j]
})
for _, n := range names {
rows = append(rows, barRow{n, s.ByBoard[n].Open, colInfo})
}
Expand Down
Loading
Loading