A Claude Code plugin that surfaces your open tasks / to-dos / waypoints as a SessionStart
banner — and, unlike resume-interrupted, it
persists until each item is explicitly marked done (it does not self-denoise). It reads an
explicit store you maintain, so the banner shows exactly what you logged — no false positives.
get-haiggoh apply (0.8.0 or later) also puts waypoints on your own shell PATH, through the
shortcuts file at this plugin's root.
Run waypoints with no arguments on a terminal and it prints the dashboard, then opens an
interactive selector over the whole command surface:
waypoints> 7 # or type the name: add-point
1) · [ship-the-tranche] Ship the manifest tranche
2) ⏳ [agy-discovery] Let AGY discover models
item (number or id): 1
bullets to APPEND (existing ones are kept) — one per line, blank line to finish:
1> reconcile the version first
$ waypoints edit ship-the-tranche --add-point 'reconcile the version first'
This exists because the store must be maintained through the CLI and never by hand, yet the
commands you need for routine upkeep are not the ones you remember — "add a bullet" is
edit <id> --add-point, which nobody recalls under pressure. The selector closes that gap so
upkeep stays possible with no Claude Code session available at all, which is exactly when
hand-editing the JSON is most tempting and most destructive.
Three properties are deliberate:
- It only ever composes an argv and hands it to the ordinary CLI. No command logic is duplicated, so every guard, confirmation, journal entry and backup behaves identically to the typed command — there is no second code path that can drift from the first.
- It echoes the command before running it. Not for confirmation but for teaching: each use shows the real invocation, so you graduate off the menu instead of depending on it.
- It opens only on a real terminal. A pipe, a script or a hook running bare
waypointsgets the plain dashboard exactly as before, because blocking those on input that never arrives would be far worse than a missing convenience.waypoints dashboardis the explicit non-interactive form, andWAYPOINTS_NO_MENU=1turns the prompt off for a terminal that does not want it.
Destructive actions keep their defaults pointed at "no", prune shows the count it would move
before asking, and recover — which replaces the whole store file — only ever offers its
read-only --list from here.
At session start, a hook reads ~/.claude/waypoints.json, keeps the items that are not
done and past any surface_on date, and prints a banner:
🧭 waypoints: 2 open waypoint(s) still ahead — they persist until done. Just ask me to add or complete one; disable via /plugin if unwanted:
• Publish the Adobe upstream PRs
(since 2026-07-12)
- branch fix/place-image; re-verify live first
- decide: one PR or two
• Re-test cutouts on corporate wifi
(since 2026-07-12)
The (since DATE) annotation always gets its own line, hanging-indented under the title —
regardless of title length or terminal width — so indentation stays consistent everywhere.
If there are no surfaceable items, it prints nothing (no empty banner).
You don't need a console command: just ask Claude to add, complete, or list waypoints — it surfaces the open ones each session and closes them for you (at the latest when you wrap up). Under the hood it uses the bundled CLI, which you can also run yourself — never by editing the JSON, which is one document whose single botched escape makes every item unreadable at once:
waypoints.py list
waypoints.py add "Publish the PR" --point "branch fix/x" --point "re-verify first" --detail "see repo X" --surface-on 2026-07-13
waypoints.py edit adobe-publish --title "Publish the PR (rebased)" --add-point "branch fix/x2" # APPENDS
waypoints.py edit adobe-publish --point "another bullet" # APPENDS (safe default)
waypoints.py edit adobe-publish --set-point 2 "fixed typo" # rewrite ONE bullet
waypoints.py edit adobe-publish --rm-point 2 # remove ONE bullet
waypoints.py edit adobe-publish --replace-points --point "only bullet now" # REPLACES (must be explicit)
waypoints.py show adobe-publish # title + summary + full detail — the "pick it up" view
waypoints.py done adobe-publish --evidence "shipped in 1.4.2, commit a1b2c3d, tests 88/88"
waypoints.py done adobe-publish --no-evidence "superseded by adobe-upstream" # no work to point at
waypoints.py reopen adobe-publish # undo a mistaken done
waypoints.py toggle adobe-publish # flip done state in one call
waypoints.py priority adobe-publish 5 # bump it ahead of others in the banner
waypoints.py reorder adobe-publish 0 # or move it to an explicit position
waypoints.py prune # MOVE every done item to the archive (destroys nothing)
waypoints.py rm adobe-publish # remove ONE item from the live store, into the archive
waypoints.py restore adobe-publish # bring an archived item back (still done)
waypoints.py archive list # the closed-item paper trail (--json for the contract)
waypoints.py archive show adobe-publish
waypoints.py rm adobe-publish --delete --confirm # permanent deletion: archive-only, two flags
waypoints.py triage adobe-publish --tier gated --gate-reason "needs your call on A vs B"
waypoints.py list --gated # also --waiting / --actionable / --untriaged, plus --open
waypoints.py list --json # documented machine-readable contractThe command is waypoints.py (Claude Code v2.1.91+ adds the plugin's bin/ to the Bash-tool PATH).
open → shown in the SessionStart banner
done → in the live store, hidden from the banner, reopenable
archived → moved out to ~/.claude/waypoints-archive.json, still readable, restorable
deleted → gone; reachable only from `archived`, only via `rm <id> --delete --confirm`
The closed list is a deliberate paper trail — it is how you reconstruct, after the fact, where
an error slipped in. So nothing that runs routinely destroys it: prune and rm both move items
to the archive, and permanent deletion takes two explicit flags, works only on an already-archived
item, and accepts one exact id (no --all, no globs). reopen <id> auto-restores from the archive
in a single step, because reopen is the safety net that catches a premature close and friction
belongs nowhere near it.
Every write is backed up first. Before overwriting the store or the archive, the current file is
copied (never moved) into ~/.claude/waypoints-backups/. Atomic writes already survive a crash;
these snapshots additionally survive a correct-but-mistaken command, which is the difference
between irreversible and recoverable. Retention keeps the last 10 snapshots plus the first of each
of the last 30 days — the daily tier is what stops one busy wrap-up from evicting the state the day
began in. Retention only ever deletes files it created itself (strict name matching), so hand-made
backups sitting nearby are never touched.
Three records with three different jobs. Reach for the right one:
| answers | kept | |
|---|---|---|
journal |
which command changed what, when | append-only, never pruned |
archive list |
how did this item resolve | until you explicitly delete it |
waypoints-backups/ |
put the whole file back | bounded ring (10 + 30 dailies) |
waypoints.py journal # the whole history
waypoints.py journal --id some-waypoint # one item's life story
waypoints.py journal --since 2026-08-01 # from a date (or a full ISO stamp)
~/.claude/waypoints-journal.jsonl gets one line per mutation: the ISO timestamp, the raw
argv, and the before/after of each item that actually changed. The reader shortens long argv
values for display (a --detail dump is often paragraphs) — the file keeps them whole. Raw argv because the literal
command is the forensic artifact — a prettified description reflects what the code believed it was
doing, which is the very thing in question when you are reading back through history.
An entry costs from ~0.5 KB for a terse item up to a few KB for one with a long detail (which is
stored twice — before and after), against ~200 KB for a full-store snapshot of a busy store. So
between one and two orders of magnitude cheaper, and that ratio is the whole design: the journal can
afford to be permanent, which is what frees the snapshot ring to be small. Store + journal replayed
backwards reconstructs any earlier state, including one whose snapshot has long since aged out.
Two properties worth knowing:
- It cannot cost you a waypoint. Recording is wrapped so a failure to journal (full disk, read-only home) returns quietly and the mutation still lands. Insurance must never break the thing it insures.
- A corrupt line costs one entry, not the file. Every mutation appends here, so a crash mid-write can leave a partial tail; unparseable lines are skipped on read. Reading history is exactly when a truncated tail must not be fatal.
Journalling is wired into the two save functions, not into each subcommand — so a command nobody thought about is still recorded. Permanent deletion is journalled too, which means the journal ends up holding the only surviving copy of a deleted item.
The banner is injected into every session's context, so it is capped: it lists the
BANNER_MAX_ITEMS (default 10) highest-priority items, trims over-long titles to one line, and
discloses the rest as a count rather than listing them. Nothing is hidden — the header counts all
open items, the residue is counted explicitly, and waypoints.py list shows everything. Tune with
$WAYPOINTS_BANNER_MAX_ITEMS and $WAYPOINTS_BANNER_TITLE_MAX.
Claude Code adds an enabled plugin's bin/ directory to the Bash tool's PATH, so the public
command is just:
waypoints # a concise dashboard: counts, the top items, what to type next
waypoints list # every item, ONE LINE each, grouped by verdict
waypoints list --verbose # ...plus bullets, gate reasons, dates and priorities
waypoints show <id> # the full detail of one item
waypoints search "kw" # find items by keyword — title, bullets AND detail
waypoints search exists because grepping the rendered list is not searching the store. The
compact view described below truncates titles at 96 characters, so:
waypoints list | grep -i spinner # ✗ finds nothing if the match sits past the ellipsis
waypoints search spinner # ✓ searches the DATA, prints the match untruncated
That is not hypothetical. Asked whether an idea was already tracked, a session grepped the list, got nothing, and filed a duplicate — the item had been tracked for two weeks, in a title whose matching words fell past the cut. The failure mode is the dangerous kind: a truncating view returns zero hits having examined nothing, which is indistinguishable from a real absence.
So search looks at the title, every bullet, and the detail field (where continuity dumps live,
invisible to every view except show), prints matching lines in full so its own output is safe
to grep, and exits 0 on a hit / 1 on a miss so it composes in a script. A miss names the scope
it searched and points at --all, because "was this ever tracked?" is answered by archived
items — add --archived to search only those, --all for both. --case, --regex and
--ids-only are there for the scripted cases.
bin/waypoints.py remains a compatibility entry point — existing notes and habits keep working.
The compact default is not cosmetic. Claude Code returns roughly 30,000 characters of a
command's output inline and saves the rest to a file, and on a real ~150-item store the old
verbose render was ~32.5 KB — just past the boundary, so the tail quietly stopped being
readable in place. The same store now renders at ~19.6 KB compact and ~1 KB as the
dashboard. Nothing was deleted; bullets, gate reasons and dates moved behind --verbose and
show.
Paging is bounded by output size as well as item count, because title lengths vary so much that a fixed item count either wastes most of the window or overshoots it:
waypoints list --limit 50 # at most 50 items per page
waypoints list --max-chars 20000 # end the page before it exceeds this
waypoints list --page 2 # the footer prints the exact next-page command
waypoints list --all # no limits — for a real terminal or a redirection
waypoints list --json # complete contract, NEVER paginated
Two guarantees hold at every budget, and each is enforced by a test rather than asserted here:
- No item is ever unreachable by paginating. A page always carries at least one whole item.
- A budget that cannot be honoured is stated, not hidden. If one item is larger than the whole budget, it still ships and the output says the budget could not be met.
A page's size is obtained by rendering the candidate page and measuring it, never by
estimating — an estimate got it wrong twice in one sitting (first by omitting section-header and
footer cost, then by a single character, which is still a breach). It is counted in UTF-8 bytes
rather than characters: bytes ≥ characters always, so a byte-safe page is safe under either
reading of the ceiling, whereas a character-safe page is not — these titles are full of multi-byte
—, ← and ⏳, which ran ~1.3% over a 26,000 budget when measured as bytes.
No hard dependencies. When more than a few items are gated, the summary line offers
/ungate-queue — a command from the separate run-to-completion plugin — but only after checking
that it is installed and enabled. Without it, the line simply points at this plugin's own
/waypoints-gated. The check fails closed, so waypoints never advertises a command your machine
does not have.
Each item has three tiers so the banner stays tidy without losing context: a short title
(headline), a few summary bullets (--add-point to append, --point to replace the whole list — the latter is refused on an item that already has bullets unless --replace-points is given), and a full detail dump
(on-demand only — read it with show). Use edit to change an item in place: it keeps the id
and created date, unlike a done+re-add.
--surface-on is the earliest date an item appears — not an expiry. An item surfaces on and
after that date and keeps showing every session until you mark it done.
Some open items aren't waiting on effort, they're waiting on something. Mark those and say what:
waypoints.py triage adobe-publish --tier gated --gate-reason "needs your call on A vs B"--tier is do-now (bounded, self-contained), heavy (doable alone but liable to sprawl),
gated (needs something this store cannot supply), or waiting (blocked only on another item
in this store reaching a milestone). Retiering away from gated drops the reason in the same
call, so unblocking is one command — and for the same reason a migration into waiting must move
the tier and the target together, or the prose is lost in between.
waypoints.py triage launcher-phase-3 --tier waiting \
--waiting-on "joyia-mcp-scope-migration @ resolved"
# --waiting-on is REPEATABLE; the item then releases only when ALL of them land
waypoints.py triage claude-mem-pilot --tier waiting \
--waiting-on "global-claude-md @ the duplication map exists" \
--waiting-on "tool-output-filtering @ a bounded tool-result path"
waypoints.py list --waiting
waypoints.py resolve
waiting is a real tier rather than another prefix inside gated, on one specific ground: the
target is an id this store already holds, so the store can re-check it for free and release the
item itself. Blocks that need a person, a recurring world condition, or an external party
share no such mechanism, so they stay prefixes inside gated — a shared state that carried no
shared behaviour would be a label pretending to be a mechanism.
- Several targets are allowed, and release requires all of them. An item blocked on two things is not freed by one of them. Conversely a single missing target makes the whole spec stale rather than partly satisfied: the store then disagrees with itself, and no partial answer is trustworthy. This is not a hypothetical shape — of the first fifteen real items migrated into this tier, three waited on two-to-four others at once, so a single-target field would have meant either guessing which dependency binds last or leaving a fifth of the pile behind.
- The milestone is required, for every target.
"<item-id> @ <milestone>"— because "when that item is done" is frequently not the actual trigger; often you are waiting on a specific point partway through. A bare id is refused, and so is awaitingitem with no target at all. - Closing a target releases its dependents automatically, to
untriaged— never to a guesseddo-now. Their own weight was never assessed whiletierwas holdingwaiting, so inventing one would be a verdict nobody made. The target and milestone are written into the item's summary on the way out, so nothing is erased. - A target that does not exist is reported as stale and never repaired. Whether the target was renamed or the dependency mistyped is not something the store can know — and a missing id must never be indistinguishable from the work having happened.
- In the banner, waiting items are marked
⏳with their target, and counted separately from gated: gated needs you, waiting needs nobody, so merging the two would tell you that you owe answers you do not owe.
In the banner, gated items are marked ⛔ in place. Once there are more than a few, the group
collapses to one counted line naming /waypoints-gated to expand it — the count is always
stated, so a collapsed group is disclosed, not hidden.
Two things this deliberately does not do:
- It does not sort gated items last. A gate reason is a question you owe yourself; burying it would assume some other tool is working through the rest of the list for you. Collapse triggers on group length, never on preference.
- It does not treat "untriaged" as "actionable". An item nobody has assessed is not thereby
unblocked, so it gets its own view (
--untriaged) instead of quietly padding the actionable pile.
Triaging is entirely optional — leave it alone and the plugin behaves exactly as before.
Never hand-edit this file. Every change goes through waypoints.py. It is one JSON document,
so a single botched escape makes json.load fail for the whole file and every item becomes
unreadable at once — not the one you were editing. If it is already broken, don't repair it by
hand either: run waypoints.py recover.
~/.claude/waypoints.json (override with $WAYPOINTS_FILE). It lives outside the
plugin so updates/reinstalls never touch your data.
{ "version": 1, "items": [
{ "id": "adobe-publish", "title": "…", "summary": ["key point", "another"], "detail": "…",
"surface_on": null, "created": "2026-07-12", "done": false }
] }tier and gate_reason are added only when you triage an item, and removed again by --clear — a
store where you never triage anything is byte-identical to one from before the feature existed.
To read the queue from a script, use list --json, not this file. It emits a contract versioned
independently of the store, so the on-disk shape stays free to change:
{ "contract": 1, "generated": "2026-08-14",
"counts": { "total": 12, "open": 9, "done": 3, "surfaceable": 9,
"gated": 2, "actionable": 4, "untriaged": 3 },
"items": [ { "id": "…", "title": "…", "summary": [], "detail": "…", "created": "…",
"surface_on": null, "done": false, "priority": 0, "surfaceable": true,
"tier": null, "gate_reason": null } ] }The journal is machine-readable by construction — one JSON object per line, each carrying its own
"contract" — so a consumer reads it directly rather than through a flag:
{"argv": ["done", "some-id"], "at": "2026-08-27T13:53:37", "contract": 1, "source": "store",
"changes": [{"id": "some-id", "before": {"done": false, "…": "…"},
"after": {"done": true, "…": "…"}}]}source is "store" or "archive", which is how a move (one line leaving the store, one
arriving in the archive) is told apart from a loss. A change with "before": null is an
addition, "after": null a removal, and "moved": [from, to] a pure reordering.
tier/gate_reason are always present here and null when unset, so a consumer never has to tell
a missing key from a null one. gated + actionable + untriaged == open — check it and you know
nothing was dropped. Filtered views add "view" and narrow items, but counts keep describing the
whole store.
A corrupt store used to read as zero items: the banner vanished silently, and the next write made that emptiness canonical. It now refuses, tells you what broke, and names the way out:
⛔ the waypoints store is unreadable — REFUSING to operate on it.
store: /Users/you/.claude/waypoints.json
reason: JSONDecodeError: Expecting ',' delimiter: line 2850 column 19
the damaged file was COPIED to (kept out of the rotating backup ring, so it cannot age out):
/Users/you/.claude/waypoints.json.corrupt-20260904-113015
newest VALID backup: …/waypoints-backups/waypoints.20260904-104112-882301-000.json (117 items)
recover with: waypoints.py recover # uses that backup
waypoints.py recover --list # see all candidates
Nothing was changed. Never hand-edit this file: every change goes through waypoints.py.
waypoints.py recover --list # the backups that actually PARSE, newest first
waypoints.py recover # put the newest valid one back, and journal it
waypoints.py recover --from PATH # choose a different snapshotThree properties worth knowing:
- Nothing is written while the store is broken. The CLI exits 2 and leaves the file alone, so a damaged store cannot be quietly replaced by an empty one.
- The recovery is journalled. A plain
cpleaves no trace inwaypoints-journal.jsonl(which records commands), so "the store went backwards" was invisible in its own history.recoveris the sanctioned path precisely because it records itself. - The evidence outlives the ring. The damaged file is copied next to the store, where the 10-deep retention sweep cannot reach it — a busy day of writes had already evicted the real incident's snapshot before it could be examined.
journalstill works while the store is unreadable; it is a separate append-only file.
A missing store is a first run, not corruption. Only one of those means "stop and recover".
Tier ordering dominates priority — a consumer works the do-now pile before anything heavy — so raising the number on a heavy item does nothing but reorder it within its tier. That left the one thing a user actually wants to say ("this heavy item, today") with no field to live in, only a prose note no mechanism can see.
waypoints.py pin big-thing --because "user wants it today; the tier order would bury it"
waypoints.py unpin big-thing- The reason is required. A pin overrules an honest verdict, so an unexplained one is indistinguishable from a mistake a month later.
- The tier is left alone. A pinned heavy item stays heavy. A pin changes when it runs, never the assessment of how big it is — fudging the verdict for scheduling reasons is the failure this field exists to prevent.
- It outranks priority rather than being a big number, so a pin can't be silently out-bid by priority inflation elsewhere.
- Pinned items get their own top section in
list, are marked 📌 in the banner with the reason (even in compact mode), and are never collapsed into the "N gated" count. - In
list --json(contract 3) they carrypinned/pin_reason, plus apinnedcount. Pinned is orthogonal to the tiers, not a fifth one, so the tier sum invariant is unchanged.
/plugin marketplace add haiggoh/get-haiggoh
/plugin install waypoints@haiggoh
It's a plugin — if you find it too naggy, disable or uninstall it via /plugin. Nothing else to
undo; your store file stays.
Distinct mechanism: separate plugin, separate store, separate banner label. resume-interrupted
answers "was my last session cut off?"; waypoints answers "what did I leave open that isn't done
yet?". No code-level dependency either way — but since resume-interrupted's banner is meant to
read as more urgent, waypoints optionally sequences after it: if resume-interrupted is installed
and enabled (detected via ~/.claude/settings.json, not an import), waypoints briefly polls a
session-scoped flag resume-interrupted writes unconditionally on exit, capped at ~0.75s, and
always prints its own banner regardless of whether that flag showed up in time. If
resume-interrupted isn't installed, this adds zero latency and never runs at all.
pytest tests/ -q
MIT — see LICENSE.