SLOPSHOPPER

roadmap

A lightweight Jira for Claude Code: milestones, epics and tasks that you and your agents plan and work from together

newpanebandguardcommandtoast
v0.7.0MITupdated 2026-10-10astrosteveo/unclaude
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · roadmap
│ ┃ Roadmap ✕ › fix the failing auth test and add an audit log call │ ┃ tab-inbox tab-plan tab-roadmap tab-board ta │ ┃ No roadmap yet. Ask Claude to plan ⏺ Read(src/auth.ts) │ ┃ milestones, epics and tasks, or press n to ⎿ Read 6 lines │ ┃ add one. ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /roadmap │ ⎿ roadmap: Roadmap opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Roadmap
tab-inbox tab-plan tab-roadmap tab-board tab-releases ░░░░ No roadmap yet. Ask Claude to plan milestones, epics and tasks, or press n to add one.
README

roadmap

A lightweight Jira for Claude Code. Milestones, epics and tasks live in your project. Claude plans them with you, claims tasks before working on them, leaves comments as it goes and marks tasks done. You follow along, and steer, from a board inside Claude Code.

◐ T16 Band above the prompt showing the current task @claude ☑1/4 · M2 3/5

Install

In a Claude Code session in your terminal:

/plugin marketplace add astrosteveo/unclaude#stable
/plugin install roadmap@unclaude

Pick a scope when asked (user scope makes it available in every project). #stable gets you the latest release: the stable branch moves only when a version is released, while main is where work lands between releases. To update later, refresh the marketplace with /plugin marketplace update unclaude and then update the plugin (claude plugin update roadmap@unclaude), or turn on auto-update for the marketplace under /plugin.

Requires the sqlite3 command line tool (pacman -S sqlite, apt install sqlite3, dnf install sqlite, brew install sqlite). If git and gh are available, they're used to link commits and pull requests to tasks.

Use it

Ask Claude to plan something, for example "plan the v2 release as milestones, epics and tasks, with a checklist on each task", and then:

  • /roadmap opens the pane: five tabs, one for each stage a piece of work goes through (see The five tabs below). It opens on the Board.
  • Keys: v steps through the tabs; t p b r d jump to a Board column; Enter opens a card, 1–5 set its status, e edits it, x closes it; n adds an item, i files something to the inbox, f filters, z undoes, w zooms the Roadmap. Hand to Claude, Assign me and Unassign have no keys on purpose: Tab to them and press Enter (Hand to Claude then asks you to confirm).
  • The hierarchy: work is epic > task > checklist. A milestone is a target, not a container: epics and tasks point at one (a task takes its epic's unless it has its own), so an epic can span milestones and a task can be pulled into an earlier or later one. A release is what ship records: the version, and the tasks it carried.
  • New items: n opens a form (kind, priority, type, where it goes, then the title; Enter creates it). On an open epic or milestone, n adds under it.
  • Edit a card with e: Tab into its title, description (one line; ask Claude for longer text), due date, labels, priority, type and parent, and press Enter on a field to save it. On a task, edit mode also lists the checklist (reword an entry, or empty it to drop it), an Add criterion field, and Blocked by (task ids, comma-separated). e again leaves edit mode.
  • Filter with f: type words to search, or narrow with @claude (@none for unassigned), #label, p0–p3, bug/feature/chore, a status (todo, wip, blocked, review, done) under:E3 or m:M2 (what targets a milestone). The filter applies to the board and the plan; Clear removes it.
  • Comments you post on a card reach Claude with your next prompt. On a card an agent holds, the button beside the comment field switches to Tells it now: then each comment starts a turn at once (the setting is kept). A ● N badge marks cards with comments you haven't read yet; Mark all read, by the unread count in the header, clears them all.
  • Ask Claude on a card puts "About roadmap task T12 (…): " in the prompt box; press Esc to finish the question there and send it.
  • Review: you review what you hand over, once. Hand Claude a task and it goes to Review when finished, not Done. Hand it a whole epic or milestone (Hand to Claude on its card, or "implement E27" in chat) and its tasks close as Claude goes; the epic or milestone goes to Review when they're all done. Open the card and press a to approve, or c to send it back with what needs changing (Claude picks it up again). When the item has an open pull request, its card shows the PR and its checks; a then asks whether to approve and merge it, or approve only, and c also posts your note on the PR. You can also tell Claude in chat that it's approved. When PRs are stacked (each based on the branch of the one before), the bottom one's card shows the stack (#11 ← #12 ← #15) and offers Merge the stack. It merges them in order: each one above the bottom is moved onto main, brought up to date with it, and merged only once its checks pass there. Each merged PR's items are approved. A failure stops the run where it is, and Claude is told why.
  • Won't do: a task you drop is closed with Won't do on its card (it asks why), or by Claude with the reason. It keeps its history and reads ✕ won't do, struck through, but it isn't finished work: it needs no ticks or release note, never reaches the CHANGELOG, and isn't counted in the done count or progress. Its parent closes as if it were done. Setting any other status takes it back to work. Claude's won't do waits on your approval, like its done.
  • Undo with z (or Undo in the header): it takes back your last change on the board (a status, a field, a tick, a comment, a new item, a removal), and pressing it again goes further back. Every change in a card's Activity has its own ↶ undo, Claude's too, and an undo has a ↷ redo. An undo is refused when something changed the same thing since, so later work is never lost. Merges can't be undone.
  • Priority and type: each task has a priority (p0 urgent to p3 can wait, p2 by default) and a type (feature, bug or chore). Cards show them when they differ from the defaults, with p0 in red and p1 in yellow.
  • Labels and links: tasks can carry labels (#ui, #auth), relate to other items (shown on both), or be marked a duplicate of another, which closes them. They show in the card's Links section.
  • Run tasks at once: tick rows in Plan's Unplanned group (☐) and press Run N at once…, or press Run its tasks at once… on an epic or milestone. Each task gets its own git worktree (.claude/worktrees/<branch>), on its own branch named for the task, made from the main line, and its own agent, started by Claude. Agents claim their tasks, commit, open their PRs and set them done with release notes, side by side. A task waiting on another starts by itself once that one is done (approved). Up to four run at once; the rest wait their turn.
  • The band above the prompt shows what an agent is working on (with several at once, each one's task and checklist). Press it to open that task.

The five tabs

Inbox — capture, and what waits on you. Needs you comes first: work in review, comments you haven't read, claims gone quiet and late work, each a press from its card. Under it, what was filed to sort: i, /roadmap inbox <text>, or Claude filing what it notices but wasn't asked to do. Each item becomes a task or an epic (the new-item form opens with its title), joins existing work (Into…: T12, or T12 checklist for an entry), or is dropped with a reason. Ask Claude to triage the inbox and it proposes a sort for you to agree before it does it.

 Inbox 4  v:  Plan   Roadmap   Board   Releases   ███████░ 96/110 done  ● 2 unread
Needs you  2
review  E21 Search
1 unread  T118 Sort the board by due date
To sort  2
I7 Export the roadmap to CSV — user, 10-10
[ → Task ] [ → Epic ] [ Into… ] [ Drop… ]
I8 Board flickers on resize — claude, 10-10
  Seen at 84 columns, while a card is docked.
[ → Task ] [ → Epic ] [ Into… ] [ Drop… ]

Plan — the hierarchy, open work first. Milestones by date with their progress, each with the epics and tasks that target it; then Unplanned, what no milestone holds. There, a todo task nobody holds has a priority picker, → Claude, and a ☐ to pick several to run at once. Finished milestones and epics fold to one line (▸ opens one: Tab to it and press Enter, or click it), and finished loose tasks fold into one line at the foot. A filter unfolds everything; whatever holds the open card stays unfolded. Done tasks say where they went: v0.6.3, or unreleased.

▾ ◐ M5 Project lifecycle: inbox to release  13/14  @claude
  ▾ ◐ E19 Roadmap: a time axis for milestones, epics and releases  2/3
      ◐ T110 Releases on the roadmap  @claude
      ● T108 Start dates for milestones and epics, given or derived  @claude
▸ ● M4 v0.5 Agent-ready tracker  15/15  @claude
Unplanned  1 for anyone to take
  ☐ p2 ○ T120 Keyboard shortcut for Mark all read  [ → Claude ]
▸ ● E16 Lean agent loop: fewer round trips and tokens per task  6/6  @claude
▸ 29 finished tasks in no epic

Roadmap — when. In a wide pane, a time axis: each epic a bar from its start (given, or its first claim) to its due date (or its milestone's), filled as far as its tasks are done; each milestone a ◆ on its date; releases as ▲ ticks; a line for today; late work in red. w zooms in around today. In a narrow pane, the same as a list: dates, progress and how each stands (in 11 days, 4 days late, 1 open) in columns. Tasks past their due date are marked ⚠late on the board, and Claude's brief lists what is overdue.

w: zoom: all                 09-28      10-05      10-12       10-19      10-26      11-02      11-09
Releases                     ▲0.6.0                ▲0.6.3 +2
M5 Project lifecycle…                              │                                         ◆
  E19 Roadmap: a time axis              ███████████│████████░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░░
  E20 Inbox: capture anyth…                  ██████│███████████████████████░░░░░░░░░░░░░░░░░
No dates yet: E22

Board — in flight, by status. In a narrow pane (under 100 columns) the columns stack: empty ones fold into one line, and each card's details (priority and type, checklist, assignee, unread comments) line up down the list. In a wide pane it's a Kanban board: an empty column shrinks to its heading, the others share the width, and a card takes one line, or its title and then its details (every card in that column, so the column lines up). Done shows what was finished in the last week (three to eight tasks); · show all lists the rest. An open card sits under the board, with its id highlighted.

t: ○ Todo 0 · b: ✗ Blocked 0 · r: ◉ Review 0

p: ◐ In progress 1
T114 Tabs in lifecycle order: Inbox · Plan · Roadmap · Board · Rel… ☑0/3 @claude

d: ● Done 110 · show all
T113 Needs you: reviews, unread comments, stale and late work at t… ☑3/3 @claude ● 1
T112 Triage: an item becomes a task or epic, joins existing work, … ☑4/4 @claude
…102 older

Releases — what shipped. Unreleased lists the notes the next release would carry, by section, with Release…: it suggests the next version (a patch when everything waiting is a fix) and runs ship, which opens the release PR. Once you've merged it, Tag and publish tags it, publishes the GitHub release and moves stable. Below, every version with its date, tasks and release PR, the newest open and the rest folded, the one installs get marked stable ●.

Unreleased 2 notes merged since the last release [ Release… ]
Added
- An Inbox: file anything to sort later, with i on the board, /roadmap inbox <text>… (T111)
- A task can be closed as won't do, with a reason: kept with its history, but not c… (T100)
▾ v0.6.3  2026-10-09  1 task  PR #33  stable ●
  Changed
  - Installing the mod gets the last release, not whatever has merged since.
▸ v0.6.2  2026-10-09  1 task  PR #31

The header reads as tabs (the one showing highlighted, a count on Inbox) beside a progress bar and the unread count, with the actions grouped apart: one row on a wide pane, two on a narrow one. The key hints at the bottom fit the width, the most useful first.

What Claude does with it:

  • Gets a short brief at the start of each session (open milestones, its own tasks, anything blocked, and your recent changes), plus a reminder if it has been working without updating its tasks.
  • Works through a handed epic or milestone in as few calls as it can: claim E7 takes the whole unit and starts its first ready task, with every task's description and checklist in the answer. Setting a task done ticks its checklist and takes its release note in the same call, then starts the next ready task. On a three-task epic that comes to four tracker calls. On tiny tasks that costs about 1.2× the same work done without the mod. On tasks that each touch several files, the difference is lost in run-to-run noise (bench/tokens).
  • Claims a task before starting it. A claim is refused if someone else holds the task or the task is still waiting on unfinished work, so parallel agents don't collide. A claim is a lease: the holder's activity keeps it alive, and once an agent has been silent for 30 minutes its claim goes stale (⌛stale on the card). Another agent can then take it over, and the takeover is logged. Subagents show up by name, such as explore:find-auth-handlers.
  • Can't mark a task done until every item on its checklist is checked, and its done goes to Review for you to approve.
  • Leaves a handoff note when it lets a task go (release with a note). The next agent to claim the task gets the note first, along with the task's description, checklist, recent activity and linked commits, so it can pick up where the last one stopped.
  • Plans a whole breakdown in one call (plan): milestones, epics and tasks nested as a tree, with checklists, labels and dependencies between the new tasks. The tree is checked in full before anything is written.
  • Searches with find: by status, assignee (none for unassigned), priority, type, labels, a subtree (under) or words in titles, descriptions and comments.
  • Uses next to pick up the next task that's ready to start, highest priority first.
  • Files what it notices but wasn't asked to do to the inbox (file), and sorts the inbox when you ask (triage): it proposes what each item becomes, and acts once you agree.
  • Works on one branch per unit you hand over, named after it (e9-agent-coordination, or t47-no-stray-hand-offs for a task on its own). When the unit goes to Review, Claude is told to push the branch and open its pull request, titled with the unit's id, with a body listing its tasks and checklists (the pr action gives the branch, title and body).
  • Gives each task a release note when it sets it done: one line for the CHANGELOG (note), and its section (Added, Changed or Fixed; by default Fixed for a bug, Changed for a chore, Added otherwise), or - when the work needs no line. A done without one is refused until it has one. When you set a task done on the board, its card asks for the note too (or None needed, or Later). The notes go in the pull request's body, and the changelog action writes the notes of merged work into CHANGELOG.md under [Unreleased], each in its section, skipping any already there.
  • Ships a release when you ask for one (ship with a version): it bumps .claude-plugin/plugin.json and package.json (those the project has), writes the release notes of merged work that aren't in the CHANGELOG yet under [Unreleased] (so running changelog first is optional), turns [Unreleased] into that version with today's date and links, and opens a PR on a release-v<version> branch. Once that PR has merged and you say so, ship again tags the merge, publishes the GitHub release from the version's notes, moves the stable branch (what installs get) to it, and records the release with the tasks it carried (shown on their cards as shipped in vX). Then it deletes the release branch, here and on origin. It refuses a version that isn't higher, and a first 1.0 unless you've asked for one.
  • Puts task ids in commit messages (T12: ...) and epic or milestone ids in PR titles and branches (E9: ..., e9-agent-coordination). Commits and PRs show up on the items they name.

How it's stored

Everything lives in .claude/roadmap.db (SQLite) in your project, shared by every Claude Code session and agent working there. The roadmap is made by the first write, and only at the top of a git repository: a session started in a subfolder uses the repository's roadmap, and one started in a folder that isn't a repository (say, the folder holding your projects) is refused, with the roadmaps it found below. Writes are transactions, so concurrent agents don't lose each other's changes. The file is binary, so it belongs in .gitignore. If it isn't ignored, the board offers to add it once (press g), or you can turn the offer down. The schema is versioned: a newer build of the mod migrates older databases on first use, and an older build refuses a newer database instead of corrupting it.

Backups

Because the database is ignored by git and lives in one checkout, deleting or re-cloning the folder would lose it. So the mod backs it up on its own: when the roadmap has changed, at most every 10 minutes while a session is open, it writes a JSON export to ~/.claude/roadmap-backups/<project path>/, named by time, and keeps the newest 20. Set ROADMAP_BACKUP_DIR to keep them somewhere else (a synced folder, say), or to off to turn them off.

To restore one, start from an empty roadmap (move .claude/roadmap.db aside if there is one) and ask Claude to "import the roadmap from ~/.claude/roadmap-backups/…/roadmap-….json". You can also ask for an export at any time (export, to .claude/roadmap-export-<date>.json or a path you name). An export holds everything: items, checklists, labels, links, the whole timeline and read marks. Ids carry on where they left off.

Development

claude --plugin-dir .                  # run a session with this checkout loaded (hot-reloads on save)
claude plugin validate .               # what the engine sees and would refuse
claude plugin test .                   # unit and UI tests (hooks/*.test.ts)
node --test tests/sql.integration.mjs  # the generated SQL against a real sqlite3
node --test tests/register.e2e.mjs     # the roadmap tool end to end (hooks/register.tsx) against a real sqlite3

node bench/tokens/run.mjs measures what the mod costs an agent. It runs the same small epic headless with this checkout loaded and without the mod, 3 runs each, and tabulates turns, tool calls, tokens and cost. These are real runs, billed to your own account: about $1.50 for the default small epic, and about $5 with --scenario notes, a heavier epic whose tasks each touch several files of a small API.

CI runs all four on every push to main and on pull requests (.github/workflows/test.yml).

This roadmap was built by working from itself: its own milestones are in this repo's .claude/roadmap.db (not committed).

Changes

See CHANGELOG.md.

License

MIT. See LICENSE.

Source 6 files
hooks/register.tsx 1899 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Draft, IssueType, Item, Kind, Pr, PlanNode, Priority, Query, Refs, Section, Snapshot, Status, View } from '../types'
5import type { IgnoreAnswer } from './model'
6import * as db from './db'
7import { drawBand, drawPane, type PaneActions, type PaneState } from './pane'
8import {
9  agentName, approvalNote, askAbout, readyIn, timeline, isMessage, cutRelease, isAfter, versionOf, webOf, withVersion, workerName, workerOf, workerPrompt, workersNote, WORKER_TYPE, WORKERS_MAX, checksOf, stackNote, stackText, statusOf, commentNote, lastChange, mergedNotes, sectionFor, sectionOf, withNotes, stackedOn, brief, handedScope, isAgent, letGo, openPrOf, branchFor, pullRequest, unitOf, CLAUDE, line, matches, checkLinks, checkPlan, PRIORITIES, TYPES, ignoreState, shouldOfferIgnore, withIgnore, checkBlockers, checkParent, detail, emptySnapshot, find, KINDS, nextUp, outline, progress, rows,
10  parseGitLog, parsePrs, refsFor, refsText, SECTIONS, STATUSES, subtree, USER, waitingOn, ancestors, noRoadmapHere, placeOf, upOf, targetOf, checkTarget, changelogVersions, shippedIn, releasesOf,
11} from './model'
12
13const PANE = 'roadmap'
14const TOOL = 'mcp__roadmap__roadmap'
15// Tools whose use counts as work that may have moved a task along.
16const WORK = new Set(['Edit', 'Write', 'NotebookEdit', 'Bash'])
17
18const snapshot = atom({ plugin: 'roadmap', key: 'snapshot' } as const, emptySnapshot())
19const view = atom({ plugin: 'roadmap', key: 'view' } as const, 'board' as View)
20const selected = atom({ plugin: 'roadmap', key: 'selected' } as const, null as string | null)
21// Whether to offer adding the database to .gitignore (see `checkIgnore`).
22const ignoreOffer = atom({ plugin: 'roadmap', key: 'ignoreOffer' } as const, false)
23let isIgnoreChecked = false
24// Whether the open card is asking what needs changing before sending it back from review.
25const requesting = atom({ plugin: 'roadmap', key: 'requesting' } as const, false)
26// The board's filter as typed, and whether its field is open.
27const filter = atom({ plugin: 'roadmap', key: 'filter' } as const, '')
28const filtering = atom({ plugin: 'roadmap', key: 'filtering' } as const, false)
29// Whether the field filing to the inbox is open.
30const filing = atom({ plugin: 'roadmap', key: 'filing' } as const, false)
31// Whether the Releases tab asks for the version to release.
32const releasing = atom({ plugin: 'roadmap', key: 'releasing' } as const, false)
33// The roadmap's zoom: 0 shows all the dated work; each step closer around today.
34const zoom = atom({ plugin: 'roadmap', key: 'zoom' } as const, 0)
35// Where each tab is scrolled to, in rows (a wide board's columns, in cards).
36const viewScrolled = atom({ plugin: 'roadmap', key: 'viewScrolled' } as const, {} as Record<string, number>)
37// With a card docked under the list, which one the wheel last moved: the card when it opens.
38const region = atom({ plugin: 'roadmap', key: 'region' } as const, 'card' as 'list' | 'card')
39// The list's rows over a docked card, as set with the divider; null sizes them by the card.
40const split = atom({ plugin: 'roadmap', key: 'split' } as const, null as number | null)
41// Just opened, the docked list keeps the open item's row in sight, until the wheel moves the list.
42const revealing = atom({ plugin: 'roadmap', key: 'revealing' } as const, false)
43// Where the docked list's frame ends, in body rows, as last drawn.
44let listEnd = 0
45// Where the tab showing is scrolled to, as last drawn (kept in sight of the open item, it may differ from the atom).
46let viewScrollAt = 0
47// How far the tab showing can scroll, as last drawn.
48let viewScrollMax = 0
49// The inbox item whose row asks where it goes (Into…) or why it's dropped (Drop…).
50const triaging = atom({ plugin: 'roadmap', key: 'triaging' } as const, null as { id: string; mode: 'into' | 'drop' } | null)
51// The milestones and epics folded otherwise than by default (a finished one folded, an open one not).
52const flipped = atom({ plugin: 'roadmap', key: 'flipped' } as const, [] as string[])
53// Whether the board's Done column shows all done work rather than the recent.
54const doneOpen = atom({ plugin: 'roadmap', key: 'doneOpen' } as const, false)
55// The new-item form's choices while it is open.
56const draft = atom({ plugin: 'roadmap', key: 'draft' } as const, null as Draft | null)
57// Whether the open card shows its fields for editing.
58const editing = atom({ plugin: 'roadmap', key: 'editing' } as const, false)
59// The item waiting on a yes before it is handed to Claude.
60const handing = atom({ plugin: 'roadmap', key: 'handing' } as const, null as string | null)
61// The item waiting on a yes before it is approved and its pull request merged.
62const merging = atom({ plugin: 'roadmap', key: 'merging' } as const, null as string | null)
63// The task just set done on the board, whose card asks for its release note.
64const noting = atom({ plugin: 'roadmap', key: 'noting' } as const, null as string | null)
65// The task whose card asks why it is dropped (won't do).
66const dropping = atom({ plugin: 'roadmap', key: 'dropping' } as const, null as string | null)
67// The item whose card waits on a yes before merging its stack of PRs, and the run's progress while one goes.
68const stacking = atom({ plugin: 'roadmap', key: 'stacking' } as const, null as string | null)
69const stackRun = atom({ plugin: 'roadmap', key: 'stackRun' } as const, '')
70// Backlog rows picked to run in parallel, and the tasks waiting on a yes before they are handed out.
71const picked = atom({ plugin: 'roadmap', key: 'picked' } as const, [] as string[])
72const parallelAsk = atom({ plugin: 'roadmap', key: 'parallelAsk' } as const, null as string[] | null)
73// Whether a comment on an agent's card starts a turn at once; the person's setting, kept across sessions.
74const commentTurns = atom({ plugin: 'roadmap', key: 'commentTurns' } as const, false)
75// How many rows the open card's sections are scrolled under its fixed title and bar.
76const scrolled = atom({ plugin: 'roadmap', key: 'scrolled' } as const, 0)
77// The furthest the open card can scroll, as last drawn.
78let scrollMax = 0
79// Commits and pull requests that name tasks, refreshed in the background (see `refreshRefs`).
80const refs = atom({ plugin: 'roadmap', key: 'refs' } as const, { commits: [], prs: [] } as Refs)
81// Why the database cannot be read, shown in the pane in place of the board.
82const problem = atom({ plugin: 'roadmap', key: 'problem' } as const, null as string | null)
83
84/** The session's root. A shell `cd` in the session moves its working directory, never this. */
85const sessionRoot = ($: EngineInterface) => $.session.root().catch(() => '.')
86
87/**
88 * Where the roadmap lives, by session root, and whether a roadmap may be started there: the root when it
89 * holds one; else, when the root is inside a git repository, that repository's top level, so a session
90 * started in a subfolder reaches the repository's roadmap; else the root itself, where none may be started.
91 */
92let home: { session: string; dir: string; isRepoTop: boolean } | undefined
93
94/**
95 * The project root, where the roadmap lives (see `home`). The database, git and gh are always found from
96 * here. A host that can't say falls back to the working directory.
97 */
98async function root($: EngineInterface): Promise<string> {
99  const session = await sessionRoot($)
100  if (home?.session !== session) home = await homeFor($, session)
101  return home.dir
102}
103
104async function homeFor($: EngineInterface, session: string) {
105  const isThere = (path: string) => $.fs.stat(path).then(() => true, () => false)
106  // The repository's top is the nearest folder up holding .git (a folder, or a worktree's file): asked
107  // of the file system, not git, so a project without a roadmap runs nothing.
108  let top: string | undefined
109  for (const dir of ancestors(session)) if (await isThere(`${dir}/.git`)) {
110    top = dir
111    break
112  }
113  if (top === session) return { session, dir: session, isRepoTop: true }
114  if (top === undefined || (await isThere(`${session}/${db.DB}`))) return { session, dir: session, isRepoTop: false }
115  return { session, dir: top, isRepoTop: true }
116}
117
118/** Roadmaps in the folders one and two levels below `dir`, for a refusal to point at. */
119async function roadmapsBelow($: EngineInterface, dir: string, depth = 2): Promise<string[]> {
120  const subs = (await $.fs.list(dir).catch(() => []))
121    .map(one => one.name)
122    .filter(name => !name.startsWith('.') && name !== 'node_modules')
123    .sort()
124    .slice(0, 200)
125  const found: string[] = []
126  for (const name of subs) {
127    const sub = `${dir}/${name}`
128    if (await $.fs.stat(`${sub}/${db.DB}`).then(() => true, () => false)) found.push(sub)
129    else if (depth > 1) found.push(...(await roadmapsBelow($, sub, depth - 1)))
130  }
131  return found
132}
133
134// Where this load started a new roadmap, said once in the reply to the write that started it.
135let startedAt: string | undefined
136
137/** Runs a command in the project root. */
138const runAt = async ($: EngineInterface, argv: string[], init: { stdin?: string; timeoutMs?: number } = {}) =>
139  $.process.run(argv, { ...init, cwd: await root($) })
140
141/** A path in the project, made absolute. */
142const inProject = async ($: EngineInterface, path: string) => `${await root($)}/${path}`
143
144// When git and gh were last asked; gh goes over the network, so it is asked far less often.
145let gitAskedAt = 0
146let ghAskedAt = 0
147const GIT_EVERY = 15_000
148const GH_EVERY = 120_000
149
150/**
151 * Re-reads the commits (and, where gh is installed and signed in, the pull requests) that name task ids.
152 * Not a git repository, no gh, no network: each just comes back empty.
153 */
154async function refreshRefs($: EngineInterface, isForced = false) {
155  const now = await $.clock.now()
156  const current = await read($, refs)
157  let { commits, prs } = current
158  if (isForced || now - gitAskedAt > GIT_EVERY) {
159    gitAskedAt = now
160    const ran = await runAt($, ['git', 'log', '-n', '1000', '--format=%h%x1f%an%x1f%as%x1f%B%x1e']).catch(() => undefined)
161    commits = ran && ran.exitCode === 0 ? parseGitLog(ran.stdout) : []
162  }
163  // gh and the remote go over the network: asked far less often than git.
164  const isRemoteTime = isForced || now - ghAskedAt > GH_EVERY
165  if (isRemoteTime) {
166    ghAskedAt = now
167    const ran = await runAt($, ['gh', 'pr', 'list', '--state', 'all', '--limit', '200', '--json', 'number,title,headRefName,baseRefName,state,url,statusCheckRollup'], { timeoutMs: 15_000 })
168      .catch(() => undefined)
169    try {
170      prs = ran && ran.exitCode === 0 ? parsePrs(ran.stdout) : []
171    } catch {
172      prs = []
173    }
174  }
175  let stable = current.stable
176  if (isRemoteTime) {
177    // The version installs get: the tag the stable branch's head carries.
178    const head = (await runAt($, ['git', 'ls-remote', '--heads', 'origin', 'stable'], { timeoutMs: 15_000 }).catch(() => undefined))?.stdout.split(/\s/)[0]
179    const tags = head ? (await runAt($, ['git', 'tag', '--points-at', head]).catch(() => undefined))?.stdout ?? '' : ''
180    stable = tags.split('\n').map(one => one.trim()).find(one => /^v\d+\.\d+\.\d+$/.test(one))?.slice(1)
181  }
182  if (JSON.stringify({ commits, prs, stable }) !== JSON.stringify(current)) await update($, refs, () => ({ commits, prs, ...(stable ? { stable } : {}) }))
183  return { commits, prs, ...(stable ? { stable } : {}) }
184}
185
186export const MISSING_SQLITE =
187  'sqlite3 is not installed or not on PATH, and the roadmap is stored with it. Install it ' +
188  '(Arch: pacman -S sqlite; Debian/Ubuntu: apt install sqlite3; Fedora: dnf install sqlite; macOS: brew install sqlite), then run /roadmap again.'
189
190// The main loop's view of the roadmap: the newest activity it has been told about, and whether it
191// has done work since it last touched the roadmap. Module state: a reload starts both over.
192let seenActivity = -1
193let hasWorkedSinceUpdate = false
194let dbStamp = ''
195let hasDir = false
196// Subagent ids to their board names, learned at spawn or from the running list.
197const agentNames = new Map<string, string>()
198
199async function actorFor($: EngineInterface, agentId: string | undefined, as: string | undefined): Promise<string> {
200  if (as?.trim()) return as.trim()
201  if (!agentId) return CLAUDE
202  const known = agentNames.get(agentId)
203  if (known) return known
204  const info = (await $.agent.list().catch(() => [])).find(one => one.id === agentId)
205  // Kept either way: an agent's name holds for its whole run, and the list isn't asked on every tool call.
206  const name = info ? agentName(info.type, info.description, info.teammateId) : `agent-${agentId.slice(0, 8)}`
207  agentNames.set(agentId, name)
208  return name
209}
210
211// When each actor's leases were last renewed, so a busy agent renews at most every RENEW_EVERY.
212const renewedAt = new Map<string, number>()
213const RENEW_EVERY = 5 * 60_000
214
215/** Renews `actor`'s leases, unless done within RENEW_EVERY (or `isForced`), in a project with a roadmap. */
216async function heartbeat($: EngineInterface, actor: string, isForced = false) {
217  const now = await $.clock.now()
218  if (!isForced && now - (renewedAt.get(actor) ?? 0) < RENEW_EVERY) return
219  renewedAt.set(actor, now)
220  if (await hasDb($)) await sql($, db.renew(actor))
221}
222
223/**
224 * Where a script runs: the project's database, or a batch's trial copy, which also keeps the writes made
225 * on it to replay on the database once the whole batch has passed (see `runBatch`).
226 */
227type Target = { path: string; writes?: string[] }
228const REAL: Target = { path: db.DB }
229
230/** Runs one script through sqlite3 and answers what its last statement printed. */
231async function run($: EngineInterface, script: string, t: Target = REAL): Promise<string> {
232  const ran = await runAt($, db.argvFor(t.path), { stdin: script }).catch(async (err: unknown) => {
233    // A command that cannot start rejects; tell a missing sqlite3 apart from, say, a timeout.
234    const isThere = await $.process.run(['sqlite3', '-version']).then(() => true, () => false)
235    throw isThere ? err : new Error(MISSING_SQLITE)
236  })
237  if (ran.exitCode !== 0) throw new Error(`sqlite3: ${ran.stderr.trim() || `exit ${ran.exitCode}`}`)
238  return db.answer(ran.stdout)
239}
240
241// Whether this load has seen the database at the schema version it reads. Cleared when the file
242// changes under us (a checkout, a copy), so a swapped-in database is checked again.
243let isSchemaReady = false
244
245/** Brings the database to this build's schema version, or throws when it is from a newer build. */
246async function ensureSchema($: EngineInterface) {
247  const version = Number(await run($, db.READ_VERSION))
248  const problem = db.versionProblem(version)
249  if (problem) throw new Error(problem)
250  if (version < db.VERSION) {
251    try {
252      await run($, db.migrate(version))
253    } catch (err) {
254      // Another session may have migrated first, and a migration that is not idempotent then fails here.
255      const now = Number(await run($, db.READ_VERSION))
256      if (now !== db.VERSION) throw err
257    }
258  }
259  isSchemaReady = true
260}
261
262async function sql($: EngineInterface, script: string, t: Target = REAL): Promise<string> {
263  if (!hasDir && !(await hasDb($))) {
264    // The first write makes the roadmap, and only where one plainly belongs: a repository's top level.
265    // Asked afresh: a `git init` since the session began makes the root a place for one.
266    home = await homeFor($, await sessionRoot($))
267    const dir = home.dir
268    if (!home.isRepoTop) throw new Error(noRoadmapHere(dir, await roadmapsBelow($, dir)))
269    // A folder that cannot be made shows up as sqlite3's own "unable to open database".
270    await runAt($, ['mkdir', '-p', '.claude']).catch(() => undefined)
271    startedAt = `${dir}/${db.DB}`
272  }
273  hasDir = true
274  if (!isSchemaReady) await ensureSchema($)
275  // Every write is a transaction of its own, begun so; reads are not kept.
276  if (t.writes && script.startsWith('BEGIN')) t.writes.push(script)
277  return run($, script, t)
278}
279
280/** A path as given to export or import: absolute, from home (`~/`), or in the project. */
281async function fileAt($: EngineInterface, path: string): Promise<string> {
282  if (path.startsWith('~/')) return `${(await $.env.get('HOME')) ?? '~'}${path.slice(1)}`
283  return path.startsWith('/') ? path : inProject($, path)
284}
285
286/** Whether the project has a roadmap yet. Reads never make one: the database is created by the first write. */
287const hasDb = async ($: EngineInterface) => $.fs.stat(await inProject($, db.DB)).then(() => true, () => false)
288
289async function refresh($: EngineInterface, t: Target = REAL): Promise<Snapshot> {
290  // A batch's trial copy is read for its own sake: the board goes on showing the database.
291  if (t !== REAL) return db.parseLoad(await sql($, db.load(USER), t))
292  try {
293    if (!(await hasDb($))) {
294      // Dormant: a project that never used the roadmap gets no file, no folder and no sqlite3.
295      const empty = emptySnapshot()
296      await update($, snapshot, () => empty)
297      await update($, problem, () => null)
298      return empty
299    }
300    const snap = db.parseLoad(await sql($, db.load(USER)))
301    await update($, snapshot, () => snap)
302    await update($, problem, () => null)
303    return snap
304  } catch (err) {
305    const message = err instanceof Error ? err.message : String(err)
306    await update($, problem, () => message)
307    throw err
308  }
309}
310
311/** The person's answers to the .gitignore offer, by project root, kept across sessions. */
312async function ignoreAnswers($: EngineInterface): Promise<Record<string, IgnoreAnswer>> {
313  return ((await $.store.get('gitignore')) ?? {}) as Record<string, IgnoreAnswer>
314}
315
316async function answerIgnore($: EngineInterface, answer: IgnoreAnswer) {
317  await $.store.set('gitignore', { ...(await ignoreAnswers($)), [await root($)]: answer })
318}
319
320/**
321 * Once the database exists, asks git whether it is ignored. A binary database in a commit is a merge
322 * conflict waiting to happen, so where it isn't, the board offers to add it; nothing is written unasked.
323 */
324async function checkIgnore($: EngineInterface) {
325  isIgnoreChecked = true
326  const ran = await runAt($, ['git', 'check-ignore', '-q', db.DB]).catch(() => undefined)
327  if (!ran) return
328  const answer = (await ignoreAnswers($))[await root($)]
329  const isOffered = shouldOfferIgnore(ignoreState(ran.exitCode), answer)
330  await update($, ignoreOffer, () => isOffered)
331  // Said once per project: after that the offer waits on the board until answered.
332  if (isOffered && answer === undefined) {
333    $.ui.toast(`roadmap: ${db.DB} isn't in .gitignore. Open /roadmap to add it.`, { timeoutMs: 8000 })
334    await answerIgnore($, 'told')
335  }
336}
337
338async function addIgnore($: EngineInterface) {
339  const file = await inProject($, '.gitignore')
340  const text = await $.fs.read(file).then(t => String(t), () => undefined)
341  await $.fs.write(file, withIgnore(text))
342  await answerIgnore($, 'added')
343  await update($, ignoreOffer, () => false)
344  $.ui.toast(`roadmap: added ${db.DB}* to .gitignore`)
345}
346
347async function dismissIgnore($: EngineInterface) {
348  await answerIgnore($, 'dismissed')
349  await update($, ignoreOffer, () => false)
350}
351
352// Automatic backups: a JSON export outside the checkout, when the roadmap changed, at most every
353// BACKUP_EVERY; the newest BACKUPS_KEPT are kept. The newest timeline entry names what a backup holds.
354const BACKUP_EVERY = 10 * 60_000
355const BACKUPS_KEPT = 20
356let backedAt = -Infinity
357let backedStamp = ''
358
359/**
360 * Where this project's backups go: ROADMAP_BACKUP_DIR when set ("off" turns them off), else
361 * ~/.claude/roadmap-backups/<the project's path, as Claude Code names its project folders>.
362 */
363async function backupDir($: EngineInterface): Promise<string | undefined> {
364  const set = (await $.env.get('ROADMAP_BACKUP_DIR'))?.trim()
365  if (set === 'off') return undefined
366  const project = (await root($)).replace(/[^A-Za-z0-9]/g, '-')
367  if (set) return `${set.replace(/\/+$/, '')}/${project}`
368  const home = await $.env.get('HOME')
369  return home ? `${home}/.claude/roadmap-backups/${project}` : undefined
370}
371
372/** Backs the roadmap up when it changed since the last backup (this session's or an earlier one's). */
373async function backup($: EngineInterface) {
374  const now = await $.clock.now()
375  if (now - backedAt < BACKUP_EVERY) return
376  backedAt = now
377  const stamp = await sql($, db.STAMP)
378  if (stamp === backedStamp) return
379  const dir = await backupDir($)
380  if (!dir) return
381  const names = (await $.fs.list(dir).catch(() => []))
382    .map(one => one.name)
383    .filter(name => /^roadmap-.*\.json$/.test(name))
384    .sort()
385  if (!names.at(-1)?.endsWith(`-a${stamp}.json`)) {
386    const at = new Date(now).toISOString()
387    const rows = JSON.parse(await sql($, db.dump())) as db.Rows
388    await $.fs.write(`${dir}/roadmap-${at.replace(/[:.]/g, '-')}-a${stamp}.json`, db.exportOf(rows, at))
389    names.push('new')
390  }
391  backedStamp = stamp
392  const old = names.slice(0, Math.max(0, names.length - BACKUPS_KEPT))
393  if (old.length) await $.process.run(['rm', '-f', ...old.map(name => `${dir}/${name}`)]).catch(() => undefined)
394}
395
396// Whether this load has looked for releases to fill in from the CHANGELOG.
397let isHistoryChecked = false
398
399/**
400 * Fills in the record of past releases, once, when the roadmap has none: each version in CHANGELOG.md, its
401 * tag and release PR where they exist, and the tasks whose notes it carried.
402 */
403async function fillReleases($: EngineInterface, known: Refs) {
404  isHistoryChecked = true
405  const snap = await read($, snapshot)
406  if ((snap.releases ?? []).length > 0) return
407  const text = await $.fs.read(await inProject($, 'CHANGELOG.md')).then(String, () => '')
408  const versions = changelogVersions(text)
409  if (versions.length === 0) return
410  const taken = new Set<string>()
411  const scripts: string[] = []
412  for (const one of versions) {
413    const tag = `v${one.version}`
414    const isTagged = (await runAt($, ['git', 'rev-parse', '-q', '--verify', `refs/tags/${tag}`]).catch(() => undefined))?.exitCode === 0
415    const pr = known.prs.find(p => p.branch === `release-${tag}` && p.state === 'merged')?.number ?? null
416    const tasks = shippedIn(snap.items, one.body, taken)
417    for (const task of tasks) taken.add(task.id)
418    scripts.push(db.recordRelease({ version: one.version, tag: isTagged ? tag : null, at: one.date, pr, notes: one.body, tasks }))
419  }
420  await sql($, db.atomic(scripts))
421  await refresh($)
422}
423
424/** Reloads when another process (an agent in another session, a git checkout) changed the database. */
425async function poll($: EngineInterface) {
426  const stamps = await Promise.all(
427    [db.DB, `${db.DB}-wal`].map(async file => $.fs.stat(await inProject($, file)).then(s => `${s.size}:${s.mtimeMs}`, () => '-')),
428  )
429  const stamp = stamps.join('|')
430  if (stamp !== dbStamp) {
431    dbStamp = stamp
432    isSchemaReady = false
433    await refresh($)
434  }
435  // Without a roadmap there is nothing to link commits to, so git and gh aren't asked.
436  if (stamps[0] === '-') return
437  // Parallel tasks whose blockers are now done start.
438  await startQueued($).catch(() => undefined)
439  // A backup that fails (no home, a full disk) never stops the board.
440  await backup($).catch(() => undefined)
441  if (!isIgnoreChecked) await checkIgnore($)
442  const known = await refreshRefs($)
443  // Never stands in the way of the board: a history that can't be read is left for another load.
444  if (!isHistoryChecked) await fillReleases($, known).catch(() => undefined)
445}
446
447type Input = {
448  action: 'show' | 'next' | 'find' | 'pr' | 'add' | 'plan' | 'update' | 'claim' | 'release' | 'comment' | 'check' | 'remove' | 'batch' | 'export' | 'import' | 'changelog' | 'ship' | 'file' | 'triage'
449  id?: string
450  ids?: string[] | string
451  ref?: string
452  ops?: Input[] | string
453  kind?: Kind
454  title?: string
455  description?: string
456  status?: Status
457  parent?: string
458  assignee?: string
459  due?: string
460  priority?: Priority
461  type?: IssueType
462  labels?: string[] | string
463  tree?: PlanNode[] | PlanNode | string
464  under?: string
465  text?: string
466  relates_to?: string[] | string
467  duplicates?: string
468  body?: string
469  blocked_by?: string[] | string
470  checklist?: string[] | string
471  items?: number[] | string
472  done?: boolean
473  as?: string
474  approved?: boolean
475  force?: boolean
476  cascade?: boolean
477  path?: string
478  note?: string
479  wontdo?: string
480  milestone?: string
481  start?: string
482  into?: string
483  fold?: 'comment' | 'checklist'
484  section?: string
485  version?: string
486}
487
488const fail = (message: string): never => {
489  throw new Error(message)
490}
491
492/**
493 * A list argument as the model may send it: a real list, a list sent as JSON text (it does that while its
494 * copy of the tool's schema predates the field), or plain text split on `separator`. Entries are trimmed,
495 * empty ones dropped.
496 */
497function listOf(value: unknown, separator: RegExp): string[] {
498  let list = value
499  if (typeof value === 'string' && value.trim().startsWith('[')) {
500    try {
501      list = JSON.parse(value)
502    } catch {
503      // Not JSON after all: split it below.
504    }
505  }
506  const entries = Array.isArray(list) ? list.map(String) : String(list ?? '').split(separator)
507  return entries.map(entry => entry.trim()).filter(Boolean)
508}
509
510/** Checklist texts: one per line when sent as plain text. */
511const texts = (value: unknown) => listOf(value, /\n/)
512
513/** `blocked_by` ids: comma-separated when sent as plain text. */
514const idList = (value: unknown) => listOf(value, /,/)
515
516/** `check` entry numbers. */
517const numbers = (value: unknown) => listOf(value, /,/).map(Number)
518
519/** A change's notes as its caller reads them back: entries ticked by number, since the caller just named them. */
520function said(notes: string[]): string[] {
521  const ticked = notes.flatMap(one => /^checked (\d+)\. /.exec(one)?.[1] ?? [])
522  const rest = notes.filter(one => !/^checked \d+\. /.test(one)).map(one => (one.startsWith('release note: ') ? 'release note set' : one))
523  return [...(ticked.length ? [`checked ${ticked.join(', ')}`] : []), ...rest]
524}
525
526/**
527 * Claims task `it` for `actor`, answering with all it takes to start cold: the task as it stands, its
528 * notes and the work already committed. Inside a unit taken whole, `unit` answers with that unit's
529 * detail in place of the task's, since it holds every task.
530 */
531async function claimTask($: EngineInterface, actor: string, snap: Snapshot, it: Item, force: boolean, t: Target, unit?: Item): Promise<string> {
532  const waiting = waitingOn(snap.items, it)
533  if (waiting.length && !force)
534    fail(`${it.id} waits on ${waiting.map(one => `${one.id} (${one.status})`).join(', ')}; finish those first, or pass force: true`)
535  const holder = (await sql($, db.claim(actor, it.id, force, it.assignee, it.status), t)) || null
536  const tookOver = it.assignee && it.assignee !== actor ? ` Took it over from ${it.assignee}${force ? '' : ', whose claim had gone stale'}.` : ''
537  if (holder !== actor) fail(`${it.id} is held by ${holder}; leave it, or pass force: true if they handed it to you`)
538  const after = await withHistory($, await refresh($, t), it.id, t)
539  // Commits are extra context: a repository that can't be asked leaves them out, not the claim.
540  const known = await refreshRefs($, true).catch(() => ({ commits: [], prs: [] }) as Refs)
541  const now = find(after.items, it.id) ?? it
542  const linked = refsText(refsFor(after.items, known, now))
543  const home = unitOf(after.items, now)
544  const where = `\nWork on branch ${branchFor(home)}${home.id === it.id ? '' : ` (${home.id}'s, which this task ships in)`}: switch to it, or create it from the branch you're building on. Commit as "${it.id}: …".`
545  // Of the history, only what people said matters to starting: who created it and when is the board's.
546  const spoken = { ...after, activity: after.activity.filter(isMessage) }
547  if (!unit) return `${it.id} is yours (${actor}), in progress.${tookOver}${where}\n\n${detail(spoken, now, 10)}${linked ? `\n${linked}` : ''}`
548  // The unit's detail carries the task's description and checklist; only a handoff note is the task's own.
549  const handoff = timeline(after.activity, it.id).filter(one => one.type === 'handoff').at(-1)
550  const note = handoff ? `\nHandoff on ${it.id} from ${handoff.author}: ${handoff.body}` : ''
551  return `${it.id} is yours, in progress.${tookOver}${where}${note}${linked ? `\n${linked}` : ''}\n\n${detail(spoken, find(after.items, unit.id) ?? unit, 5)}`
552}
553
554/**
555 * Takes a milestone or epic handed over whole: `actor` holds it, so its tasks close as they go and it
556 * goes to review once, and the first task ready in it is claimed. One call starts the unit.
557 */
558async function takeUnit($: EngineInterface, actor: string, snap: Snapshot, unit: Item, force: boolean, t: Target): Promise<string> {
559  if (isAgent(unit.assignee) && unit.assignee !== actor && !force)
560    fail(`${unit.id} is held by ${unit.assignee}; leave it, or pass force: true if they handed it to you`)
561  if (unit.assignee !== actor) {
562    const { script } = db.change(actor, unit, { assignee: actor })
563    if (script) await sql($, script, t)
564  }
565  const held = await refresh($, t)
566  const now = find(held.items, unit.id) ?? unit
567  const head = `${unit.id} is yours (${actor}): its tasks close as you finish them, and it goes to review once they all have.`
568  const task = readyIn(held.items, now, actor)
569  if (task) return `${head}\n${await claimTask($, actor, held, task, false, t, now)}`
570  const left = progress(held.items, now)
571  const why = left.done === left.total ? 'all its tasks are done' : 'its open tasks are held by others or wait on unfinished work'
572  return `${head} Nothing in it to start: ${why}.\n\n${detail({ ...held, activity: held.activity.filter(isMessage) }, now, 5)}`
573}
574
575/**
576 * Carries out one roadmap action for `actor`. Agents don't close their own work: their `done` goes to
577 * review, and only the person (on the board) or the main loop passing on their approval sets done.
578 */
579async function act($: EngineInterface, actor: string, a: Input, isSubagent = false, t: Target = REAL): Promise<string> {
580  // The same change to several items is a batch of one op per item: all of them, or none.
581  if (a.ids !== undefined && a.action !== 'batch') return runBatch($, actor, [a], isSubagent)
582  if (a.action === 'batch') return runBatch($, actor, opsOf(a.ops), isSubagent)
583  const snap = await refresh($, t)
584  const item = find(snap.items, a.id)
585  const need = () => item ?? fail(a.id ? `No item ${a.id}` : 'id is required')
586  if (a.status && !STATUSES.includes(a.status)) fail(`status must be one of ${STATUSES.join(', ')}`)
587  if (a.priority && !PRIORITIES.includes(a.priority)) fail(`priority must be one of ${PRIORITIES.join(', ')}`)
588  if (a.type && !TYPES.includes(a.type)) fail(`type must be one of ${TYPES.join(', ')}`)
589  if (a.due && !/^\d{4}-\d{2}-\d{2}$/.test(a.due)) fail('due must be a date, YYYY-MM-DD')
590  if (a.start && !/^\d{4}-\d{2}-\d{2}$/.test(a.start)) fail('start must be a date, YYYY-MM-DD')
591  const startsKind = a.action === 'add' ? a.kind : a.id ? find(snap.items, a.id)?.kind : undefined
592  if (a.start && startsKind === 'task') fail('Only milestones and epics take a start date; a task starts when it is claimed')
593
594  switch (a.action) {
595    case 'show':
596      if (a.id) {
597        const it = need()
598        const linked = refsText(refsFor(snap.items, await refreshRefs($, true), it))
599        return detail(await withHistory($, snap, it.id, t), it, 15, await read($, refs)) + (linked ? `\n${linked}` : '')
600      }
601      return outline(snap.items) || 'The roadmap is empty.'
602    case 'next': {
603      const up = nextUp(snap.items, actor, await $.clock.now().catch(() => undefined)).slice(0, 5)
604      if (up.length === 0) return 'Nothing open: no tasks assigned to you and no unassigned todo tasks.'
605      return up.map(task => detail(snap, task, 5)).join('\n\n')
606    }
607    case 'find': {
608      const query: Query = {
609        kind: a.kind,
610        status: a.status ? [a.status] : undefined,
611        assignee: a.assignee ? [a.assignee] : undefined,
612        priority: a.priority ? [a.priority] : undefined,
613        type: a.type ? [a.type] : undefined,
614        labels: a.labels === undefined ? undefined : idList(a.labels).map(db.label),
615        under: a.under || undefined,
616        milestone: a.milestone || undefined,
617        text: a.text || undefined,
618      }
619      if (query.under && !find(snap.items, query.under)) fail(`No item ${query.under}`)
620      // Text is looked for in everything ever written on an item, not only the snapshot's recent part.
621      const said = query.text ? (JSON.parse(await sql($, db.said, t)) as Record<string, string>) : undefined
622      const found = rows(snap.items).map(row => row.item).filter(one => matches(snap, one, query, said))
623      if (found.length === 0) return 'Nothing matches.'
624      const cap = 40
625      return [
626        `${found.length} match${found.length === 1 ? '' : 'es'}:`,
627        ...found.slice(0, cap).map(one => `${line(snap.items, one)}${upOf(one) ? ` [${upOf(one)}]` : ''}`),
628        ...(found.length > cap ? [`…${found.length - cap} more; narrow the search`] : []),
629      ].join('\n')
630    }
631    case 'pr': {
632      const unit = unitOf(snap.items, need())
633      const pr = pullRequest(snap.items, unit)
634      const open = (await refreshRefs($, true).catch(() => ({ commits: [], prs: [] }) as Refs)).prs.find(
635        one => one.state === 'open' && one.ids.includes(unit.id),
636      )
637      return [
638        open ? `${unit.id} already has PR #${open.number} (${open.url}): push to its branch to update it.` : `${unit.id} has no open PR.`,
639        `Branch: ${pr.branch}`,
640        `Title: ${pr.title}`,
641        'Body:',
642        pr.body,
643      ].join('\n')
644    }
645    case 'add': {
646      if (!a.kind || !KINDS.includes(a.kind)) fail(`kind must be one of ${KINDS.join(', ')}`)
647      if (!a.title?.trim()) fail('title is required')
648      if (a.blocked_by !== undefined && a.kind !== 'task') fail('Only tasks wait on other tasks')
649      // All checked before the insert (links against a placeholder id no existing item has), so a call
650      // that fails leaves nothing behind for a retry to duplicate.
651      const blockers = a.blocked_by === undefined ? [] : checkBlockers(snap.items, '\u0000new', idList(a.blocked_by))
652      const checklist = a.checklist === undefined ? [] : texts(a.checklist)
653      if (checklist.length && a.kind !== 'task') fail('Only tasks carry a checklist')
654      const related = a.relates_to === undefined ? [] : checkLinks(snap.items, '\u0000new', idList(a.relates_to))
655      const original = a.duplicates ? checkLinks(snap.items, '\u0000new', [a.duplicates]) : []
656      const tags = a.labels === undefined ? [] : idList(a.labels)
657      const place = placeOf(snap.items, a.kind!, a.parent)
658      // A target of its own: on an epic, or on a task in an epic (where it overrides the epic's).
659      if (a.milestone) {
660        const target = checkTarget(snap.items, a.kind!, a.milestone)
661        if (place.milestone && target !== place.milestone) fail(`under ${place.milestone}, ${a.kind} already targets it; leave milestone out or put it under ${target}`)
662        place.milestone = target
663      }
664      const { note, section } = noteOf(a)
665      if ((note || section) && a.kind !== 'task') fail('Only tasks carry a release note')
666      const id = await sql($, db.insert(actor, {
667        kind: a.kind!,
668        title: a.title!.trim(),
669        ...place,
670        description: a.description,
671        start: a.start,
672        due: a.due,
673        status: a.status,
674        assignee: a.assignee,
675        priority: a.priority || undefined,
676        type: a.type || undefined,
677      }), t)
678      if (blockers.length || checklist.length || related.length || original.length || tags.length || note || section) {
679        const created = find((await refresh($, t)).items, id) as Item
680        if (note || section) await sql($, db.change(actor, created, { note, section }).script, t)
681        if (blockers.length) await sql($, db.setBlockers(actor, created, blockers).script, t)
682        if (checklist.length) await sql($, db.setChecklist(actor, created, checklist).script, t)
683        if (tags.length) await sql($, db.setLabels(actor, created, tags).script, t)
684        if (related.length) await sql($, db.setRelations(actor, created, 'relates', related).script, t)
685        if (original.length) await sql($, db.setRelations(actor, created, 'duplicates', original).script, t)
686      }
687      return `Added ${id}: ${a.title!.trim()}${blockers.length ? `, blocked by ${blockers.join(', ')}` : ''}`
688    }
689    case 'update': {
690      const it = need()
691      // Won't do: the task is closed, with the reason why, as dropped rather than finished.
692      const wontdo = a.wontdo === undefined ? undefined : a.wontdo.trim() || fail("wontdo takes the reason the task is dropped")
693      if (wontdo !== undefined) {
694        if (it.kind !== 'task') fail("Only tasks close as won't do; a milestone or epic closes when its tasks have")
695        if (a.status !== undefined && a.status !== 'done') fail("wontdo closes the task: leave status out")
696        a.status = 'done'
697      }
698      // Closing what was dropped (its review approved) needs no ticks or note: it isn't finished work.
699      const isDropped = wontdo !== undefined || (it.resolution === 'wontdo' && a.status === 'done')
700      if (a.checklist !== undefined && it.kind !== 'task') fail('Only tasks carry a checklist')
701      // Done can tick the entries it finishes in the same call: one write closes the task.
702      const ticks = a.items === undefined ? [] : numbers(a.items)
703      if (ticks.length && (a.status !== 'done' || a.checklist !== undefined))
704        fail('items goes with status done (to tick entries as the task closes); use check to tick them otherwise')
705      const strays = ticks.filter(n => !it.checklist.some(c => c.n === n))
706      if (strays.length) fail(`${it.id} has no checklist entry ${strays.join(', ')}`)
707      const list = (a.checklist === undefined ? it.checklist : db.setChecklistPreview(it, texts(a.checklist))).map(c =>
708        ticks.includes(c.n) ? { ...c, done: true } : c,
709      )
710      const open = list.filter(c => !c.done)
711      if (a.status === 'done' && open.length && !a.force && !isDropped)
712        fail(
713          `${it.id} has ${open.length} unchecked item(s): ${open.map(c => `${c.n}. ${c.text}`).join('; ')}. ` +
714            'Check them (action check), or pass force: true to close it anyway.',
715        )
716      if (a.approved && actor === USER) a.approved = undefined
717      if (a.approved && isSubagent) fail('Only the user approves work; a subagent sets done and it goes to review.')
718      if (a.approved && a.status !== 'done') fail('approved goes with status: done')
719      const { note, section } = noteOf(a)
720      if ((note !== undefined || section !== undefined) && it.kind !== 'task') fail('Only tasks carry a release note')
721      // An agent's done asks for the task's line in the CHANGELOG, unless it has one.
722      if (a.status === 'done' && it.kind === 'task' && actor !== USER && !(note ?? it.note) && !isDropped)
723        fail(
724          `${it.id} has no release note. Send status done again with note: one line for the CHANGELOG, saying what changed for ` +
725            `whoever uses the project (and section: ${SECTIONS.join(', ')}; ${sectionFor(it)} by default); or note: "-" when the work needs no line (tests, refactors).`,
726        )
727      // An agent's done waits on the user's approval in review; the person's own is final. Inside a
728      // milestone or epic handed over whole, a task's done is final too: the review comes once, on that.
729      const scope = it.kind === 'task' ? handedScope(snap.items, it) : undefined
730      const isToReview = a.status === 'done' && actor !== USER && !a.approved && !scope
731      const left = progress(snap.items, it)
732      if (isToReview && it.kind !== 'task' && left.done < left.total)
733        fail(`${it.id} closes when its tasks are done; finish those (they close as you go when ${it.id} is assigned to you)`)
734      // Every argument checked before the first write, so a call that fails changes nothing.
735      if (a.blocked_by !== undefined && it.kind !== 'task') fail('Only tasks wait on other tasks')
736      const place = a.parent === undefined ? undefined : placeOf(snap.items, it.kind, a.parent, it.id)
737      const target = a.milestone === undefined ? undefined : checkTarget(snap.items, it.kind, a.milestone)
738      const blockers = a.blocked_by === undefined ? undefined : checkBlockers(snap.items, it.id, idList(a.blocked_by))
739      const related = a.relates_to === undefined ? undefined : checkLinks(snap.items, it.id, idList(a.relates_to))
740      const original = a.duplicates === undefined ? undefined : checkLinks(snap.items, it.id, idList(a.duplicates))
741      const { script, notes } = db.change(actor, it, {
742        title: a.title?.trim() || undefined,
743        status: isToReview ? 'review' : a.status,
744        description: a.description === undefined ? undefined : a.description || null,
745        due: a.due === undefined ? undefined : a.due || null,
746        start: a.start === undefined ? undefined : a.start || null,
747        assignee: a.assignee === undefined ? undefined : a.assignee || null,
748        // Unassigned without a status of its own (the board's Unassign), a task under way goes back to todo.
749        ...(a.assignee === '' && a.status === undefined && it.assignee ? { status: letGo(it).status } : {}),
750        priority: a.priority || undefined,
751        type: a.type || undefined,
752        note,
753        section,
754        ...(place ?? {}),
755        ...(target === undefined ? {} : { milestone: target }),
756        // Back to work (todo, in progress, blocked), a dropped task is no longer won't do.
757        resolution: wontdo !== undefined ? 'wontdo' : it.resolution && a.status && !['done', 'review'].includes(a.status) ? null : undefined,
758      })
759      // One script, one transaction: the update lands whole or not at all.
760      const parts = [
761        wontdo === undefined ? undefined : { script: db.comment(actor, it.id, `Won't do: ${wontdo}`), notes: [] as string[] },
762        ticks.length ? db.check(actor, it, ticks, true) : undefined,
763        a.checklist === undefined ? undefined : db.setChecklist(actor, it, texts(a.checklist)),
764        blockers === undefined ? undefined : db.setBlockers(actor, it, blockers),
765        a.labels === undefined ? undefined : db.setLabels(actor, it, idList(a.labels)),
766        related === undefined ? undefined : db.setRelations(actor, it, 'relates', related),
767        original === undefined ? undefined : db.setRelations(actor, it, 'duplicates', original),
768      ].filter(part => part !== undefined)
769      const all = db.atomic([script, ...parts.map(part => part.script)])
770      if (all) await sql($, all, t)
771      notes.push(...parts.flatMap(part => part.notes))
772      if (isToReview && isDropped) notes.push("waiting on the user's approval to drop it. They approve on the board; pass approved: true only when they tell you in chat")
773      else if (isToReview) {
774        notes.push("waiting on the user's approval. They approve on the board; pass approved: true only when they tell you in chat")
775        // The review point is where its pull request opens: one per unit of work handed over.
776        const pr = pullRequest(snap.items, it)
777        notes.push(
778          `Now open its pull request, if it has none: push branch ${pr.branch}, then gh pr create --title "${pr.title}" ` +
779            `with the body from the pr action (pr ${it.id}); base it on main, or on the branch it was built on when that isn't merged yet`,
780        )
781      }
782      else if (scope && a.status === 'done' && actor !== USER && scope.assignee === actor) {
783        // Working through a unit they hold, the agent goes straight on to its next ready task.
784        const after = await refresh($, t)
785        const unit = find(after.items, scope.id) ?? scope
786        const task = readyIn(after.items, unit, actor, it.id)
787        if (task) return `${it.id}: ${said(notes).join('; ')}\nNext in ${unit.id}: ${await claimTask($, actor, after, task, false, t)}`
788        const left = progress(after.items, unit)
789        if (left.done < left.total) notes.push(`nothing else in ${unit.id} is ready: its open tasks are held by others or wait on unfinished work`)
790        else {
791          const pr = pullRequest(after.items, unit)
792          notes.push(
793            `that was the last task in ${unit.id}, which now waits on the user's review. Open its pull request, if it has none: push branch ${pr.branch}, ` +
794              `then gh pr create --title "${pr.title}" with the body from the pr action (pr ${unit.id})`,
795          )
796        }
797      }
798      else if (scope && a.status === 'done' && actor !== USER)
799        notes.push(`closed as part of ${scope.id}, which the user reviews as a whole once all its tasks are done`)
800      else if (a.approved) notes.push('approved by the user')
801      return notes.length ? `${it.id}: ${said(notes).join('; ')}` : `${it.id}: nothing changed`
802    }
803    case 'claim': {
804      const it = need()
805      return it.kind === 'task' ? claimTask($, actor, snap, it, a.force === true, t) : takeUnit($, actor, snap, it, a.force === true, t)
806    }
807    case 'release': {
808      const it = need()
809      // The note goes in first, so the timeline reads: what was left, then who let go.
810      if (a.body?.trim()) await sql($, db.comment(actor, it.id, a.body.trim(), 'handoff'), t)
811      const { script } = db.change(actor, it, letGo(it))
812      if (script) await sql($, script, t)
813      return `${it.id} released${a.body?.trim() ? ', with your handoff note' : ''}.`
814    }
815    case 'check': {
816      const it = need()
817      if (!it.checklist.length) fail(`${it.id} has no checklist; set one with update checklist`)
818      const ns = numbers(a.items ?? [])
819      const unknown = ns.filter(n => !it.checklist.some(c => c.n === n))
820      if (ns.length === 0 || unknown.length)
821        fail(`items must name entries 1–${it.checklist.length}${unknown.length ? `; there is no ${unknown.join(', ')}` : ''}`)
822      const { script, notes } = db.check(actor, it, ns, a.done !== false)
823      if (script) await sql($, script, t)
824      const left = it.checklist.filter(c => !(ns.includes(c.n) ? a.done !== false : c.done)).length
825      return `${it.id}: ${notes.length ? said(notes).join('; ') : 'nothing changed'}. ${left ? `${left} left to check.` : 'All checked.'}`
826    }
827    case 'comment': {
828      const it = need()
829      if (!a.body?.trim()) fail('body is required')
830      await sql($, db.comment(actor, it.id, a.body!.trim()), t)
831      return `Commented on ${it.id}.`
832    }
833    case 'plan': {
834      let tree: unknown = a.tree
835      if (typeof tree === 'string') {
836        try {
837          tree = JSON.parse(tree)
838        } catch {
839          fail('tree must be a list of items (JSON)')
840        }
841      }
842      const nodes = (Array.isArray(tree) ? tree : tree ? [tree] : []) as PlanNode[]
843      if (nodes.length === 0) fail('tree is required: a list of { kind, title, …, children }')
844      // All or nothing up front: nothing is written until the whole tree has passed.
845      const planned = checkPlan(snap.items, nodes, a.parent || undefined)
846      const ids = new Map<string, string>()
847      // Milestones first (they sit under nothing), so anything in the plan can target one by its ref.
848      for (const one of [...planned.filter(one => one.node.kind === 'milestone'), ...planned.filter(one => one.node.kind !== 'milestone')]) {
849        const n = one.node
850        // Under a milestone (new or not), an item targets it rather than sitting in it.
851        const under = one.parentRef ? ids.get(one.parentRef)! : one.parentId
852        const isTarget = one.parentRef ? planned.find(other => other.ref === one.parentRef)!.node.kind === 'milestone' : find(snap.items, one.parentId ?? undefined)?.kind === 'milestone'
853        const id = await sql($, db.insert(actor, {
854          kind: n.kind,
855          title: n.title.trim(),
856          parent: isTarget ? null : under,
857          milestone: n.milestone ? ids.get(String(n.milestone).trim()) ?? find(snap.items, String(n.milestone).trim())!.id : isTarget ? under : null,
858          description: n.description,
859          due: n.due,
860          start: n.start,
861          assignee: n.assignee,
862          priority: n.priority,
863          type: n.type,
864        }), t)
865        ids.set(one.ref, id)
866      }
867      const after = (await refresh($, t)).items
868      for (const one of planned) {
869        const created = find(after, ids.get(one.ref))!
870        const blockers = [...one.blockerRefs.map(ref => ids.get(ref)!), ...one.blockerIds]
871        if (blockers.length) await sql($, db.setBlockers(actor, created, blockers).script, t)
872        if (one.node.checklist?.length) await sql($, db.setChecklist(actor, created, texts(one.node.checklist)).script, t)
873        if (one.node.labels?.length) await sql($, db.setLabels(actor, created, idList(one.node.labels)).script, t)
874      }
875      const final = (await refresh($, t)).items
876      const roots = planned.filter(one => !one.parentRef).map(one => ids.get(one.ref)!)
877      return [
878        `Planned ${planned.length} item(s): ${planned.map(one => `${one.ref} → ${ids.get(one.ref)}`).join(', ')}`,
879        ...roots.map(id => [line(final, find(final, id)!), outline(final, id)].filter(Boolean).join('\n')),
880      ].join('\n')
881    }
882    case 'export': {
883      const at = new Date(await $.clock.now().catch(() => Date.now())).toISOString()
884      const path = a.path?.trim() || `.claude/roadmap-export-${at.slice(0, 10)}.json`
885      const rows = JSON.parse(await sql($, db.dump(), t)) as db.Rows
886      await $.fs.write(await fileAt($, path), db.exportOf(rows, at))
887      return `Exported ${rows.items?.length ?? 0} item(s) and ${rows.activity?.length ?? 0} timeline entries to ${path}. import (path) restores it into an empty roadmap.`
888    }
889    case 'import': {
890      if (!a.path?.trim()) fail('path is required: the export to restore')
891      const text = await $.fs.read(await fileAt($, a.path!.trim())).then(String, () => fail(`cannot read ${a.path}`))
892      const rows = db.importOf(text)
893      const [items, entries] = (await sql($, db.COUNT, t)).split(' ').map(Number)
894      if (items || entries)
895        fail(`the roadmap here already holds ${items} item(s) and ${entries} timeline entries; import goes only into an empty one (move ${db.DB} aside first)`)
896      await sql($, db.importRows(rows), t)
897      return `Imported ${rows.items?.length ?? 0} item(s) and ${rows.activity?.length ?? 0} timeline entries from ${a.path!.trim()}.`
898    }
899    case 'changelog': {
900      // Merged work: what a merged PR (or none, on the main line) shipped; never what is still open.
901      const known = await refreshRefs($, true).catch(() => ({ commits: [], prs: [] }) as Refs)
902      const notes = mergedNotes(snap.items, known).map(task => ({ section: sectionFor(task), note: task.note! }))
903      const path = a.path?.trim() || 'CHANGELOG.md'
904      const file = await fileAt($, path)
905      const text = await $.fs.read(file).then(String, () => undefined)
906      const out = withNotes(text, notes)
907      if (out.added.length === 0)
908        return notes.length ? `${path} already has the notes of all merged work.` : 'No merged work has a release note yet.'
909      await $.fs.write(file, out.text.replace(/\n*$/, '\n'))
910      return `Wrote ${out.added.length} note(s) into ${path} under [Unreleased]:\n${out.added.map(one => `- ${one}`).join('\n')}`
911    }
912    case 'file': {
913      const title = a.title?.trim() || fail('file takes a title: what to sort later')
914      const id = await sql($, db.fileInbox(actor, title, a.description?.trim() || null), t)
915      return `Filed ${id} to the inbox: ${title}`
916    }
917    case 'triage': {
918      const waiting = (snap.inbox ?? []).filter(one => one.state === 'open')
919      if (!a.id) {
920        if (waiting.length === 0) return 'The inbox is empty.'
921        return [
922          `The inbox (${waiting.length}): propose to the user what each becomes (a task or epic, and where; part of existing work; or dropped), and triage each once they agree.`,
923          ...waiting.map(one => `- ${one.id} ${one.title}${one.body ? `: ${one.body.replace(/\s+/g, ' ')}` : ''} (${one.author}, ${one.at.slice(0, 10)})`),
924        ].join('\n')
925      }
926      const one = (snap.inbox ?? []).find(item => item.id.toUpperCase() === a.id!.trim().toUpperCase()) ?? fail(`No inbox item ${a.id}`)
927      if (one.state !== 'open') fail(`${one.id} was already ${one.state === 'dropped' ? 'dropped' : `sorted into ${one.became}`}`)
928      const filed = `Filed to the inbox as ${one.id} by ${one.author} on ${one.at.slice(0, 10)}.`
929      const ways = [a.kind !== undefined, a.into !== undefined, a.wontdo !== undefined].filter(Boolean).length
930      if (ways !== 1) fail('triage takes one of: kind (it becomes a task or epic), into (it joins existing work), wontdo (it is dropped, with the reason)')
931      if (a.wontdo !== undefined) {
932        const reason = a.wontdo.trim() || fail('wontdo takes the reason it is dropped')
933        await sql($, db.resolveInbox(one.id, 'dropped', null, reason), t)
934        return `${one.id} dropped: ${reason}`
935      }
936      if (a.into !== undefined) {
937        const target = find(snap.items, a.into.trim()) ?? fail(`No item ${a.into}`)
938        const isEntry = a.fold === 'checklist'
939        if (isEntry && target.kind !== 'task') fail('Only tasks carry a checklist; fold it in as a comment instead')
940        const script = isEntry
941          ? db.setChecklist(actor, target, [...target.checklist.map(c => c.text), one.title]).script
942          : db.comment(actor, target.id, `${one.title}${one.body ? `\n\n${one.body}` : ''}\n\n(${filed})`)
943        await sql($, db.atomic([script, db.resolveInbox(one.id, 'triaged', target.id, null)]), t)
944        return `${one.id} joined ${target.id} as ${isEntry ? 'a checklist entry' : 'a comment'}.`
945      }
946      if (a.kind !== 'task' && a.kind !== 'epic') fail('An inbox item becomes a task or an epic')
947      const reply = await act($, actor, {
948        action: 'add', kind: a.kind, title: a.title?.trim() || one.title, description: [one.body, filed].filter(Boolean).join('\n\n'),
949        parent: a.parent, milestone: a.milestone, priority: a.priority, type: a.type,
950      }, isSubagent, t)
951      const made = /Added (\w+)/.exec(reply)?.[1] ?? fail(`could not make ${one.id} into a ${a.kind}: ${reply}`)
952      await sql($, db.resolveInbox(one.id, 'triaged', made, null), t)
953      return `${one.id} became ${made}: ${a.title?.trim() || one.title}`
954    }
955    case 'ship':
956      return ship($, snap.items, a.version, a.approved === true && !isSubagent)
957    case 'remove': {
958      const it = need()
959      const ids = subtree(snap.items, it.id)
960      if (ids.length > 1 && !a.cascade)
961        fail(`${it.id} has ${ids.length - 1} item(s) under it; pass cascade: true to remove them too`)
962      // What it held, kept with the removal so an undo can put it all back; nobody may write in between.
963      const stamp = await sql($, db.STAMP, t)
964      const rows = JSON.parse(await sql($, db.dump(ids), t)) as db.Rows
965      const body = `removed ${it.kind} “${it.title}”${ids.length > 1 ? ` and the ${ids.length - 1} item(s) under it` : ''}`
966      await sql($, db.atomic([db.expectStamp(stamp), db.remove(ids, { actor, body, rows })]), t)
967      return `Removed ${ids.join(', ')}`
968    }
969  }
970  return fail(`Unknown action ${a.action}`)
971}
972
973/**
974 * Takes back the logged changes `ids` as `actor`, all or none, each logged as an undo that can itself be
975 * undone. A comment is deleted; an add removes the item (one with nothing under it); a removal puts back
976 * everything it took. Refused when what a change set has changed since, so nothing later is lost.
977 */
978async function undo($: EngineInterface, actor: string, ids: number[], t: Target = REAL): Promise<string> {
979  if (ids.length === 0) fail('Nothing to undo')
980  const stamp = await sql($, db.STAMP, t)
981  const found = JSON.parse(await sql($, db.entries(ids), t)) as db.Entry[]
982  if (found.length < ids.length) fail('That change is no longer in the timeline')
983  const snap = await refresh($, t)
984  const list: { entry: db.Entry; undo: string; redo: string }[] = []
985  for (const entry of found) {
986    if (entry.undone) fail(`“${entry.body}” was already undone`)
987    if (entry.type === 'comment' || entry.type === 'handoff') list.push({ entry, undo: db.unsay(entry), redo: db.resay(entry) })
988    else if (entry.type === 'create') {
989      const it = find(snap.items, entry.item_id) ?? fail(`${entry.item_id} is already gone`)
990      if (subtree(snap.items, it.id).length > 1) fail(`${it.id} has items under it; remove or move those first`)
991      const rows = JSON.parse(await sql($, db.dump([it.id]), t)) as db.Rows
992      list.push({ entry, undo: db.removeRows([it.id]), redo: db.restore(rows) })
993    } else if (entry.undo && entry.redo) list.push({ entry, undo: entry.undo, redo: entry.redo })
994    else fail(`“${entry.body}” on ${entry.item_id} can't be taken back (it was logged before undo existed); change it directly`)
995  }
996  try {
997    await sql($, db.revert(actor, list, stamp), t)
998  } catch (err) {
999    const message = err instanceof Error ? err.message : String(err)
1000    fail(db.guardReason(message) ?? message)
1001  }
1002  return `Undid ${[...found].sort((a, b) => a.id - b.id).map(one => `${one.item_id}: ${one.body}`).join('; ')}`
1003}
1004
1005// The manifests whose version a release sets, those the project has.
1006const MANIFESTS = ['.claude-plugin/plugin.json', 'package.json']
1007
1008/** Runs git in the project; one that cannot start answers as a failure. */
1009const git = async ($: EngineInterface, args: string[]): Promise<Ran> =>
1010  runAt($, ['git', ...args], { timeoutMs: 60_000 }).catch((err: unknown) => ({ exitCode: -1, stdout: '', stderr: err instanceof Error ? err.message : String(err) }))
1011
1012/**
1013 * Ships a version, in two steps. While the project is before `version`: bumps its manifests, cuts the
1014 * CHANGELOG's [Unreleased] as that version, commits that on a branch of its own and opens its PR. Once
1015 * that PR has merged (the manifests now read `version`) and the user has said so (`approved`): tags the
1016 * merge and publishes a GitHub release from the version's notes. Refuses a version that isn't higher,
1017 * and a first 1.0 without the user's say.
1018 */
1019// The branch installs come from (`/plugin marketplace add <owner>/<repo>#stable`): ship moves it to each release.
1020const STABLE = 'stable'
1021
1022async function ship($: EngineInterface, items: Item[], raw: string | undefined, approved: boolean): Promise<string> {
1023  const wanted = versionOf(raw) ?? fail('version is required, as 1.2.3')
1024  const version = wanted.join('.')
1025  const read = async (path: string) => $.fs.read(await inProject($, path)).then(String, () => undefined)
1026  const manifests = (await Promise.all(MANIFESTS.map(async path => ({ path, text: await read(path) }))))
1027    .filter((one): one is { path: string; text: string } => one.text !== undefined && withVersion(one.text, version) !== undefined)
1028  if (manifests.length === 0) fail(`no manifest with a version here (${MANIFESTS.join(', ')})`)
1029  const current = versionOf((/"version"\s*:\s*"([^"]*)"/.exec(manifests[0]!.text) ?? [])[1]) ?? fail(`${manifests[0]!.path} has no version like 1.2.3`)
1030  const branch = `release-v${version}`
1031  const tag = `v${version}`
1032  const text = await read('CHANGELOG.md')
1033
1034  if (current.join('.') === version) {
1035    // The bump is in: its PR merged. Tagging and publishing are the user's to say.
1036    if ((await git($, ['rev-parse', '-q', '--verify', `refs/tags/${tag}`])).exitCode === 0) fail(`${tag} is already tagged; ${version} is out`)
1037    const pr = await gh($, ['pr', 'list', '--head', branch, '--state', 'merged', '--json', 'number,mergeCommit', '--limit', '1'], 30_000)
1038    const merged = pr.exitCode === 0 ? (JSON.parse(pr.stdout || '[]') as { number: number; mergeCommit?: { oid?: string } }[])[0] : undefined
1039    if (!merged?.mergeCommit?.oid) fail(`no merged PR from ${branch} yet: merge the release PR first`)
1040    if (!approved)
1041      fail(`PR #${merged!.number} for ${version} has merged. Tagging ${tag} and publishing the release is the user's call: ask them, and when they say so, send ship again with approved: true.`)
1042    const notes = (text ?? '').split('\n')
1043    const at = notes.findIndex(line => line.startsWith(`## [${version}]`))
1044    const next = notes.findIndex((line, i) => i > at && (line.startsWith('## ') || /^\[[^\]]+\]: \S/.test(line)))
1045    const body = at < 0 ? '' : notes.slice(at + 1, next < 0 ? undefined : next).join('\n').trim()
1046    await git($, ['fetch', 'origin'])
1047    const tagged = await git($, ['tag', '-a', tag, '-m', tag, merged!.mergeCommit!.oid!])
1048    if (tagged.exitCode !== 0) fail(`git tag failed: ${whyNot(tagged)}`)
1049    const pushed = await git($, ['push', 'origin', tag])
1050    if (pushed.exitCode !== 0) fail(`pushing ${tag} failed: ${whyNot(pushed)}`)
1051    const out = await gh($, ['release', 'create', tag, '--title', tag, '--verify-tag', '--notes', body || `Release ${version}.`])
1052    if (out.exitCode !== 0) fail(`${tag} is tagged and pushed, but gh release create failed: ${whyNot(out)}`)
1053    // What installs get: the stable branch, moved here and only here, to the release (a fast-forward; a
1054    // stable that went elsewhere is left alone and said so).
1055    const stable = await git($, ['push', 'origin', `${merged!.mergeCommit!.oid!}:refs/heads/${STABLE}`]).catch(() => undefined)
1056    const served = stable?.exitCode === 0 ? ` ${STABLE} now serves ${version}.` : ` ${STABLE} was not moved (${stable ? whyNot(stable) : 'git failed'}): installs still get the release before.`
1057    // The record of what shipped: the tasks whose notes this version carries, none already in another.
1058    const now = await refresh($)
1059    const taken = new Set((now.releases ?? []).filter(one => one.version !== version).flatMap(one => one.tasks.map(task => task.id)))
1060    const tasks = shippedIn(now.items, body, taken)
1061    await sql($, db.recordRelease({ version, tag, at: new Date(await $.clock.now()).toISOString().slice(0, 10), pr: merged!.number, notes: body, tasks }))
1062    // The release branch has done its work: gone here and on origin. A branch that won't go never fails the release.
1063    const local = await git($, ['branch', '-D', branch]).catch(() => undefined)
1064    // GitHub may have deleted it on the merge already.
1065    const onOrigin = (await git($, ['ls-remote', '--heads', 'origin', branch]).catch(() => undefined))?.stdout.trim()
1066    const remote = onOrigin ? await git($, ['push', 'origin', '--delete', branch]).catch(() => undefined) : { exitCode: 0 }
1067    const left = [local?.exitCode === 0 ? '' : 'here', remote?.exitCode === 0 ? '' : 'on origin'].filter(Boolean)
1068    return `Released ${version}: tagged ${tag} on PR #${merged!.number}'s merge and published ${out.stdout.trim() || 'the GitHub release'}.${served}` +
1069      (left.length < 2 ? ` Deleted ${branch}${left.length ? ` (still ${left[0]})` : ''}.` : '')
1070  }
1071
1072  if (!isAfter(wanted, current)) fail(`${version} isn't after ${current.join('.')}, the version now`)
1073  if (wanted[0] >= 1 && current[0] < 1 && !approved)
1074    fail(`${version} would be the first 1.x: the major version stays at 0 until the user says otherwise. Ask them; send approved: true once they have.`)
1075  if (text === undefined) fail('no CHANGELOG.md to cut the release from')
1076  const remote = await git($, ['remote', 'get-url', 'origin'])
1077  if (remote.exitCode !== 0) fail('no git remote "origin" to open the release PR on')
1078  // The release's own file may differ (notes written by changelog): it goes into the release commit.
1079  const changed = (await git($, ['status', '--porcelain'])).stdout.split('\n').filter(line => line.trim() && line.slice(3).trim() !== 'CHANGELOG.md')
1080  if (changed.length) fail('the working tree has changes; commit or stash them first')
1081  // A release is cut from the main line as it stands on origin: never from a feature branch, never stale.
1082  const mainLine = (await git($, ['symbolic-ref', '--short', 'refs/remotes/origin/HEAD'])).stdout.trim().replace(/^origin\//, '') || 'main'
1083  const on = (await git($, ['rev-parse', '--abbrev-ref', 'HEAD'])).stdout.trim()
1084  if (on !== mainLine) fail(`the checkout is on ${on || 'no branch'}; a release is cut from ${mainLine}: switch to it and pull first`)
1085  await git($, ['fetch', 'origin', mainLine])
1086  const behind = Number((await git($, ['rev-list', '--count', `HEAD..origin/${mainLine}`])).stdout.trim()) || 0
1087  if (behind) fail(`${mainLine} is ${behind} commit(s) behind origin/${mainLine}; pull first`)
1088  // The notes of merged work not in the CHANGELOG yet go under [Unreleased] first, so they are released too.
1089  const known = await refreshRefs($, true).catch(() => ({ commits: [], prs: [] }) as Refs)
1090  const merged = withNotes(text, mergedNotes(items, known).map(task => ({ section: sectionFor(task), note: task.note! })))
1091  const cut = cutRelease(merged.text, version, new Date(await $.clock.now()).toISOString().slice(0, 10), webOf(remote.stdout))
1092  const made = await git($, ['switch', '-c', branch])
1093  if (made.exitCode !== 0) fail(`could not make branch ${branch}: ${whyNot(made)}`)
1094  for (const one of manifests) await $.fs.write(await inProject($, one.path), withVersion(one.text, version)!)
1095  await $.fs.write(await inProject($, 'CHANGELOG.md'), cut.text)
1096  const committed = await git($, ['commit', '-am', `Release ${version}`])
1097  if (committed.exitCode !== 0) fail(`git commit failed: ${whyNot(committed)}`)
1098  const pushed = await git($, ['push', '-u', 'origin', branch])
1099  if (pushed.exitCode !== 0) fail(`pushing ${branch} failed: ${whyNot(pushed)}`)
1100  // The bump lives on its branch and PR; the checkout goes back to the main line it was cut from.
1101  await git($, ['switch', mainLine])
1102  const pr = await gh($, ['pr', 'create', '--head', branch, '--title', `Release ${version}`, '--body', cut.notes])
1103  if (pr.exitCode !== 0) fail(`${branch} is pushed, but gh pr create failed: ${whyNot(pr)}`)
1104  return (
1105    `Opened ${pr.stdout.trim() || 'the release PR'} for ${version}: ${manifests.map(one => one.path).join(' and ')} bumped, ` +
1106    `${merged.added.length ? `${merged.added.length} release note(s) of merged work added and ` : ''}CHANGELOG [Unreleased] cut as ${version}; back on ${mainLine}. ` +
1107    `Once it has merged, pull ${mainLine} and send ship ${version} again; it tags and publishes the release when the user says so (approved: true).`
1108  )
1109}
1110
1111/** A release note and section as sent: `-` or `none` for no line needed, empty to clear; the section checked. */
1112function noteOf(a: Input): { note?: string | null; section?: Section | null } {
1113  const note = a.note === undefined ? undefined : ['-', 'none'].includes(a.note.trim().toLowerCase()) ? db.NO_NOTE : a.note.trim() || null
1114  const section = a.section === undefined ? undefined : a.section.trim() === '' ? null : sectionOf(a.section) ?? fail(`section must be one of ${SECTIONS.join(', ')}`)
1115  return { note, section }
1116}
1117
1118/** The snapshot with `id`'s whole timeline in place of the recent part it carries. */
1119async function withHistory($: EngineInterface, snap: Snapshot, id: string, t: Target = REAL): Promise<Snapshot> {
1120  const all = JSON.parse(await sql($, db.history(id), t)) as Snapshot['activity']
1121  return { ...snap, activity: [...snap.activity.filter(one => one.item_id !== id), ...all] }
1122}
1123
1124/** A batch's ops as sent: a list, or a list as JSON text. */
1125function opsOf(value: unknown): Input[] {
1126  let list = value
1127  if (typeof list === 'string') {
1128    try {
1129      list = JSON.parse(list)
1130    } catch {
1131      fail('ops must be a list of { action, ... } (JSON)')
1132    }
1133  }
1134  if (!Array.isArray(list) || list.length === 0) fail('ops is required: a list of { action, ... }')
1135  return list as Input[]
1136}
1137
1138// The fields that name items, which a batch reads a ref in: an item added earlier in the same batch.
1139const NAMING = ['id', 'parent', 'duplicates', 'ids', 'blocked_by', 'relates_to'] as const
1140
1141/**
1142 * Runs ops in order, all or nothing. They run first on a copy of the database, so each sees what the ones
1143 * before it did and every check runs as it would; the writes they made are then replayed on the database
1144 * in one transaction, which rolls back if anyone else wrote in between. An op that fails writes nothing.
1145 */
1146async function runBatch($: EngineInterface, actor: string, raw: Input[], isSubagent: boolean): Promise<string> {
1147  // `ids` makes one op per item.
1148  const ops = raw.flatMap(op =>
1149    op.ids === undefined ? [op] : idList(op.ids).map(id => ({ ...op, ids: undefined, id })))
1150  if (ops.length === 0) fail('ids names no items')
1151  if (ops.some(op => op.action === 'batch' || op.ops !== undefined)) fail('a batch cannot hold another batch')
1152  // What writes files or talks to git and GitHub can't be tried on a copy and taken back.
1153  const outside = ops.find(op => ['export', 'import', 'changelog', 'ship'].includes(op.action))
1154  if (outside) fail(`${outside.action} can't go in a batch; send it on its own`)
1155  await sql($, db.STAMP) // the database, made and brought to this schema version if need be
1156  const now = await $.clock.now().catch(() => 0)
1157  const copy: Target = { path: `${db.DB}-batch-${now}-${Math.random().toString(36).slice(2, 8)}`, writes: [] }
1158  // .backup copies what the database holds, the write-ahead log included, as one consistent read.
1159  const backed = await runAt($, ['sqlite3', db.DB, `.backup '${copy.path}'`])
1160  if (backed.exitCode !== 0) fail(`could not copy the roadmap to try the batch: ${backed.stderr.trim()}`)
1161  const stamp = await sql($, db.STAMP, copy)
1162  const answers: string[] = []
1163  const refs = new Map<string, string>()
1164  const named = (value: unknown) => (typeof value === 'string' ? refs.get(value.trim()) ?? value : value)
1165  try {
1166    for (const [i, raw] of ops.entries()) {
1167      const fields: Record<string, unknown> = { ...raw }
1168      for (const field of NAMING) {
1169        const value = fields[field]
1170        if (Array.isArray(value)) fields[field] = value.map(named)
1171        else if (typeof value === 'string' && (field === 'blocked_by' || field === 'relates_to' || field === 'ids'))
1172          fields[field] = idList(value).map(named)
1173        else if (value !== undefined) fields[field] = named(value)
1174      }
1175      const op = fields as Input
1176      try {
1177        const answer = await act($, actor, op, isSubagent, copy)
1178        answers.push(`${i + 1}. ${answer}`)
1179        const added = /^Added (\w+)/.exec(answer)?.[1]
1180        if (op.ref && added) refs.set(String(op.ref).trim(), added)
1181      } catch (err) {
1182        fail(`op ${i + 1} (${op.action}${op.id ? ` ${op.id}` : ''}): ${err instanceof Error ? err.message : String(err)}. Nothing in the batch was written.`)
1183      }
1184    }
1185  } finally {
1186    await runAt($, ['rm', '-f', copy.path, `${copy.path}-wal`, `${copy.path}-shm`]).catch(() => undefined)
1187  }
1188  if (copy.writes!.length) {
1189    try {
1190      await sql($, db.atomic([db.expectStamp(stamp), ...copy.writes!]))
1191    } catch (err) {
1192      const moved = (await sql($, db.STAMP).catch(() => stamp)) !== stamp
1193      fail(moved
1194        ? 'the roadmap changed while the batch was being checked; nothing in it was written. Send it again.'
1195        : `the batch passed its checks but could not be written: ${err instanceof Error ? err.message : String(err)}. Nothing in it was written.`)
1196    }
1197  }
1198  return answers.join('\n')
1199}
1200
hooks/model.ts 1174 lines
1import type { Activity, Checks, Commit, IssueType, Item, Kind, PlanNode, PlannedItem, Pr, Priority, Query, Refs, Release, Section, Snapshot, Status } from '../types'
2
3// The person at the board, and the main loop's agent; subagents go by names from agentName.
4export const USER = 'user'
5export const CLAUDE = 'claude'
6export const KINDS: Kind[] = ['milestone', 'epic', 'task']
7export const STATUSES: Status[] = ['todo', 'in_progress', 'blocked', 'review', 'done']
8export const GLYPH: Record<Status, string> = { todo: '○', in_progress: '◐', blocked: '✗', review: '◉', done: '●' }
9export const LABEL: Record<Status, string> = { todo: 'Todo', in_progress: 'In progress', blocked: 'Blocked', review: 'Review', done: 'Done' }
10export const PRIORITIES: Priority[] = ['p0', 'p1', 'p2', 'p3']
11export const TYPES: IssueType[] = ['feature', 'bug', 'chore']
12export const SECTIONS: Section[] = ['Added', 'Changed', 'Fixed']
13// The order Keep a Changelog puts its sections in, those this mod writes among them.
14const SECTION_ORDER = ['Added', 'Changed', 'Deprecated', 'Removed', 'Fixed', 'Security']
15
16/** A section as given (`fixed`, `Fixed`), or undefined when it is none of them. */
17export const sectionOf = (text: string | undefined): Section | undefined =>
18  SECTIONS.find(one => one.toLowerCase() === text?.trim().toLowerCase())
19
20/** The section a task's note goes under: its own, else what its type suggests (a bug is a fix). */
21export const sectionFor = (item: Item): Section => item.section ?? (item.type === 'bug' ? 'Fixed' : item.type === 'chore' ? 'Changed' : 'Added')
22
23/** Whether a task has a note worth a CHANGELOG line (not none, and not `-`, none needed). */
24/** A task closed as won't do: closed, but dropped rather than finished. */
25export const isDropped = (item: Item) => item.resolution === 'wontdo'
26export const hasNote = (item: Item) => Boolean(item.note && item.note !== '-') && !isDropped(item)
27/** Priority and type as worth saying: the defaults (p2, feature) go without saying. */
28// How a task closed as won't do is marked.
29export const WONTDO_GLYPH = '✕'
30export const marks = (item: Item) =>
31  [item.priority && item.priority !== 'p2' ? item.priority : '', item.type && item.type !== 'feature' ? item.type : ''].filter(Boolean)
32const byPriority = (a: Item, b: Item) => PRIORITIES.indexOf(a.priority ?? 'p2') - PRIORITIES.indexOf(b.priority ?? 'p2')
33export const PREFIX: Record<Kind, string> = { milestone: 'M', epic: 'E', task: 'T' }
34// Which kinds each kind may sit under.
35const PARENTS: Record<Kind, Kind[]> = { milestone: [], epic: ['milestone'], task: ['epic', 'milestone'] }
36
37export const emptySnapshot = (): Snapshot => ({ items: [], activity: [], seen: {} })
38
39/**
40 * Lookups over one list of items, built the first time it is asked about. A snapshot's list is replaced
41 * on every load, never changed in place, so an index stays true for as long as its list is around.
42 */
43type Index = { byId: Map<string, Item>; children: Map<string | null, Item[]>; status: Map<Item, Status> }
44const indexes = new WeakMap<Item[], Index>()
45
46function indexOf(items: Item[]): Index {
47  let index = indexes.get(items)
48  if (!index) {
49    index = { byId: new Map(), children: new Map(), status: new Map() }
50    for (const item of items) {
51      const key = item.id.toUpperCase()
52      if (!index.byId.has(key)) index.byId.set(key, item)
53      const up = upOf(item)
54      const siblings = index.children.get(up)
55      if (siblings) siblings.push(item)
56      else index.children.set(up, [item])
57    }
58    indexes.set(items, index)
59  }
60  return index
61}
62
63/**
64 * What an item sits under in the tree: a task its epic, else (a task in no epic, or an epic) the milestone it
65 * targets. A milestone is a target, not a container, but the tree shows what targets it beneath it.
66 */
67export const upOf = (item: Item): string | null => item.parent ?? item.milestone ?? null
68
69/** The milestone a task or epic targets: its own, else (a task) its epic's; a milestone is its own. */
70export function targetOf(items: Item[], item: Item): string | null {
71  if (item.kind === 'milestone') return item.id
72  if (item.milestone) return item.milestone
73  const up = find(items, item.parent ?? undefined)
74  // Under a milestone, as data from before targets had it: that milestone; under an epic, the epic's.
75  if (!up) return null
76  return up.kind === 'milestone' ? up.id : item.kind === 'task' ? targetOf(items, up) : null
77}
78
79export const find = (items: Item[], id: string | undefined) =>
80  id === undefined ? undefined : indexOf(items).byId.get(id.toUpperCase())
81
82/** An item's children, as a list of the caller's own (free to sort). */
83export const childrenOf = (items: Item[], id: string | null): Item[] => [...(indexOf(items).children.get(id) ?? [])]
84
85/**
86 * Where "under X" puts an item: under an epic, `parent` is the epic (and a task's own target goes, so it
87 * follows its epic's); under a milestone, `milestone` is it and there is no epic; under nothing, neither.
88 */
89export function placeOf(items: Item[], kind: Kind, under: string | undefined, self?: string): { parent: string | null; milestone: string | null } {
90  const at = checkParent(items, kind, under, self)
91  const found = at ? find(items, at) : undefined
92  return found?.kind === 'milestone' ? { parent: null, milestone: found.id } : { parent: at, milestone: null }
93}
94
95/** The milestone a target names, or throws: only epics and tasks target, and only milestones are targets. */
96export function checkTarget(items: Item[], kind: Kind, milestone: string): string | null {
97  if (milestone === '') return null
98  if (kind === 'milestone') throw new Error('A milestone targets nothing; epics and tasks target milestones')
99  const found = find(items, milestone)
100  if (!found) throw new Error(`No item ${milestone}`)
101  if (found.kind !== 'milestone') throw new Error(`${found.id} is a ${found.kind}; a target is a milestone`)
102  return found.id
103}
104
105/** The parent id to store, or throws when the nesting is not allowed. */
106export function checkParent(items: Item[], kind: Kind, parent: string | undefined, self?: string): string | null {
107  if (parent === undefined || parent === '') return null
108  const found = find(items, parent)
109  if (!found) throw new Error(`No item ${parent}`)
110  if (found.id === self) throw new Error(`${self} cannot be its own parent`)
111  if (!PARENTS[kind].includes(found.kind)) throw new Error(`A ${kind} cannot sit under a ${found.kind} (${found.id})`)
112  return found.id
113}
114
115/** How long a claim lasts without a sign of life from its holder before anyone may take it over. */
116export const LEASE_MS = 30 * 60_000
117
118/**
119 * Whether a task's claim has gone quiet: in progress under someone whose last heartbeat (or, for a claim
120 * made before leases, the task's last change) is older than LEASE_MS.
121 */
122export function isStale(item: Item, now: number): boolean {
123  if (item.kind !== 'task' || item.status !== 'in_progress' || !item.assignee) return false
124  const at = Date.parse(item.lease_at ?? item.updated_at)
125  return Number.isFinite(at) && now - at > LEASE_MS
126}
127
128/**
129 * A whole plan checked before anything is written, flattened parents first, or throws naming the first
130 * problem: a bad kind or nesting, a missing title, a ref used twice, a blocker that is neither a new
131 * task nor an existing one, or new tasks waiting on each other in a cycle.
132 */
133export function checkPlan(items: Item[], nodes: PlanNode[], parent: string | undefined): PlannedItem[] {
134  const out: PlannedItem[] = []
135  const refs = new Map<string, PlannedItem>()
136  const walk = (list: PlanNode[], parentId: string | null, parentRef: string | null, parentKind: Kind | null) => {
137    for (const node of list) {
138      const ref = String(node.ref ?? `#${out.length + 1}`).trim()
139      const where = `${ref}${node.title ? ` (${node.title})` : ''}`
140      if (!KINDS.includes(node.kind)) throw new Error(`${where}: kind must be one of ${KINDS.join(', ')}`)
141      if (!node.title?.trim()) throw new Error(`${where}: title is required`)
142      if (refs.has(ref)) throw new Error(`ref ${ref} is used twice`)
143      if (find(items, ref)) throw new Error(`ref ${ref} is an existing item's id; pick another`)
144      if (node.priority && !PRIORITIES.includes(node.priority)) throw new Error(`${where}: priority must be one of ${PRIORITIES.join(', ')}`)
145      if (node.type && !TYPES.includes(node.type)) throw new Error(`${where}: type must be one of ${TYPES.join(', ')}`)
146      if (node.kind !== 'task' && (node.checklist?.length || node.blocked_by?.length))
147        throw new Error(`${where}: only tasks carry a checklist or blocked_by`)
148      if (node.milestone !== undefined) {
149        if (node.kind === 'milestone') throw new Error(`${where}: a milestone targets nothing`)
150        const named = String(node.milestone).trim()
151        const local = nodes.flatMap(function all(one: PlanNode): PlanNode[] { return [one, ...(one.children ?? []).flatMap(all)] }).find(one => one.ref === named)
152        if (local ? local.kind !== 'milestone' : (find(items, named)?.kind ?? 'none') !== 'milestone')
153          throw new Error(`${where}: milestone ${named} is not a milestone here or in the plan`)
154      }
155      if (parentKind === null) checkParent(items, node.kind, parentId ?? undefined)
156      else if (!PARENTS[node.kind].includes(parentKind)) throw new Error(`${where}: a ${node.kind} cannot sit under a ${parentKind}`)
157      const planned: PlannedItem = { ref, node, parentId: parentRef ? null : parentId, parentRef, blockerRefs: [], blockerIds: [] }
158      refs.set(ref, planned)
159      out.push(planned)
160      walk(node.children ?? [], null, ref, node.kind)
161    }
162  }
163  const top = parent ? find(items, parent)?.id ?? null : null
164  if (parent && !top) throw new Error(`No item ${parent}`)
165  walk(nodes, top, null, null)
166  for (const one of out) {
167    for (const raw of one.node.blocked_by ?? []) {
168      const name = String(raw).trim()
169      const local = refs.get(name)
170      if (local) {
171        if (local.node.kind !== 'task') throw new Error(`${one.ref}: only tasks block tasks; ${name} is a ${local.node.kind}`)
172        if (local === one) throw new Error(`${one.ref} cannot block itself`)
173        one.blockerRefs.push(local.ref)
174      } else one.blockerIds.push(...checkBlockers(items, '\u0000new', [name]))
175    }
176  }
177  // Existing tasks can't wait on new ones, so a cycle can only run through the new tasks.
178  const state = new Map<string, 'open' | 'done'>()
179  const visit = (ref: string) => {
180    if (state.get(ref) === 'done') return
181    if (state.get(ref) === 'open') throw new Error(`blocked_by runs in a cycle through ${ref}`)
182    state.set(ref, 'open')
183    for (const next of refs.get(ref)!.blockerRefs) visit(next)
184    state.set(ref, 'done')
185  }
186  for (const one of out) visit(one.ref)
187  return out
188}
189
190// Words a query reads as a status; the rest of the words are searched for.
191const STATUS_WORDS: Record<string, Status> = {
192  todo: 'todo', 'in-progress': 'in_progress', in_progress: 'in_progress', wip: 'in_progress', blocked: 'blocked', review: 'review', done: 'done',
193}
194
195/**
196 * A query as typed on the board: `@claude` (assignee; `@none` for unassigned), `#ui` (label), `p0`–`p3`,
197 * `bug`/`feature`/`chore`, a status word (`todo`, `wip`, `blocked`, `review`, `done`), `under:E3`, and
198 * any other words, which must all appear in the text. Repeats of a kind widen it: `p0 p1` is either.
199 * Undefined when there is nothing to look for.
200 */
201export function parseQuery(text: string): Query | undefined {
202  const q: Query = {}
203  const add = <K extends 'status' | 'assignee' | 'priority' | 'type' | 'labels'>(key: K, value: NonNullable<Query[K]>[number]) =>
204    ((q[key] as unknown[] | undefined) ??= []).push(value)
205  const words: string[] = []
206  for (const word of text.trim().split(/\s+/).filter(Boolean)) {
207    const low = word.toLowerCase()
208    if (low.startsWith('@') && low.length > 1) add('assignee', low.slice(1))
209    else if (low.startsWith('#') && low.length > 1) add('labels', low.slice(1))
210    else if (low.startsWith('under:') && low.length > 6) q.under = low.slice(6).toUpperCase()
211    else if (low.startsWith('m:') && low.length > 2) q.milestone = low.slice(2).toUpperCase()
212    else if ((PRIORITIES as string[]).includes(low)) add('priority', low as Priority)
213    else if ((TYPES as string[]).includes(low)) add('type', low as IssueType)
214    else if (STATUS_WORDS[low]) add('status', STATUS_WORDS[low]!)
215    else words.push(word)
216  }
217  if (words.length) q.text = words.join(' ')
218  return Object.keys(q).length ? q : undefined
219}
220
221/**
222 * Whether `item` is what `query` looks for. Status is the rolled-up one, as the board shows it. Text is
223 * looked for in what was written on the item: `said` (every message, by item id) when given, else the
224 * snapshot's recent timeline.
225 */
226export function matches(snap: Snapshot, item: Item, query: Query, said?: Record<string, string>): boolean {
227  if (query.kind && item.kind !== query.kind) return false
228  if (query.status?.length && !query.status.includes(statusOf(snap.items, item))) return false
229  if (query.assignee?.length && !query.assignee.some(who => (who === 'none' ? !item.assignee : item.assignee?.toLowerCase() === who.toLowerCase())))
230    return false
231  if (query.priority?.length && !query.priority.includes(item.priority ?? 'p2')) return false
232  if (query.type?.length && !query.type.includes(item.type ?? 'feature')) return false
233  if (query.labels?.length && !query.labels.some(one => (item.labels ?? []).includes(one))) return false
234  if (query.under) {
235    const root = find(snap.items, query.under)
236    if (!root || root.id === item.id || !subtree(snap.items, root.id).includes(item.id)) return false
237  }
238  if (query.milestone && item.id !== query.milestone.toUpperCase() && targetOf(snap.items, item) !== query.milestone.toUpperCase()) return false
239  if (query.text?.trim()) {
240    const words = query.text.toLowerCase().split(/\s+/).filter(Boolean)
241    const written = said ? [said[item.id] ?? ''] : snap.activity.filter(one => one.item_id === item.id && isMessage(one)).map(one => one.body)
242    const hay = [item.id, item.title, item.description ?? '', ...written].join('\n').toLowerCase()
243    if (!words.every(word => hay.includes(word))) return false
244  }
245  return true
246}
247
248/** The tasks `item` waits on that are not done yet. */
249export const waitingOn = (items: Item[], item: Item): Item[] =>
250  (item.blocked_by ?? []).map(id => find(items, id)).filter((one): one is Item => one !== undefined && one.status !== 'done')
251
252/** The tasks that wait on `item`. */
253export const blocks = (items: Item[], item: Item): Item[] => items.filter(one => (one.blocked_by ?? []).includes(item.id))
254
255/**
256 * The blocker ids to store for task `id`, normalized, or throws: each must be another task, and none may
257 * already wait on `id`, directly or through others (that would be a cycle nobody can finish).
258 */
259export function checkBlockers(items: Item[], id: string, blockers: string[]): string[] {
260  const out: string[] = []
261  for (const raw of blockers) {
262    const found = find(items, raw.trim())
263    if (!found) throw new Error(`No item ${raw}`)
264    if (found.kind !== 'task') throw new Error(`Only tasks block tasks; ${found.id} is a ${found.kind}`)
265    if (found.id.toUpperCase() === id.toUpperCase()) throw new Error(`${found.id} cannot block itself`)
266    const seen = new Set<string>()
267    const stack = [found.id]
268    while (stack.length) {
269      const at = find(items, stack.pop())
270      if (!at || seen.has(at.id)) continue
271      seen.add(at.id)
272      if (at.id.toUpperCase() === id.toUpperCase()) throw new Error(`${found.id} already waits on ${id}; that would be a cycle`)
273      stack.push(...(at.blocked_by ?? []))
274    }
275    if (!out.includes(found.id)) out.push(found.id)
276  }
277  return out
278}
279
280/** The item ids to link to, normalized, or throws: each must exist and not be `id` itself. */
281export function checkLinks(items: Item[], id: string, ids: string[]): string[] {
282  const out: string[] = []
283  for (const raw of ids) {
284    const found = find(items, raw.trim())
285    if (!found) throw new Error(`No item ${raw}`)
286    if (found.id.toUpperCase() === id.toUpperCase()) throw new Error(`${found.id} cannot link to itself`)
287    if (!out.includes(found.id)) out.push(found.id)
288  }
289  return out
290}
291
292/**
293 * An item's links other than blocked-by, read from both ends: `relates` goes both ways, so an item
294 * relates to those it names and to those that name it; a duplicate names its original.
295 */
296export function linksOf(items: Item[], item: Item) {
297  const out = (type: string) => (item.relations ?? []).filter(one => one.type === type).map(one => one.id)
298  const into = (type: string) =>
299    items.filter(one => (one.relations ?? []).some(r => r.type === type && r.id === item.id)).map(one => one.id)
300  return {
301    relates: [...new Set([...out('relates'), ...into('relates')])],
302    duplicateOf: out('duplicates'),
303    duplicatedBy: into('duplicates'),
304  }
305}
306
307/** Where a new item of `kind` may go: the open milestones (and, for a task, epics) it can sit under. */
308export const homesFor = (items: Item[], kind: Kind): Item[] =>
309  rows(items)
310    .map(row => row.item)
311    .filter(one => PARENTS[kind].includes(one.kind) && statusOf(items, one) !== 'done')
312
313/** Ids in an item's subtree, the item first. */
314export function subtree(items: Item[], id: string): string[] {
315  const out = [id]
316  for (let i = 0; i < out.length; i++) out.push(...childrenOf(items, out[i]!).map(child => child.id))
317  return out
318}
319
320/**
321 * The tasks an item is made of: an epic's, its tasks; a milestone's, the tasks that target it, directly
322 * or through their epic (a task can target another milestone than its epic's).
323 */
324export const tasksIn = (items: Item[], item: Item): Item[] =>
325  item.kind === 'milestone'
326    ? items.filter(one => one.kind === 'task' && targetOf(items, one) === item.id)
327    : subtree(items, item.id)
328        .map(id => find(items, id)!)
329        .filter(one => one.kind === 'task' && one.id !== item.id)
330const tasksUnder = tasksIn
331
332/** Tasks in an item's subtree: done and total. */
333export function progress(items: Item[], item: Item): { done: number; total: number } {
334  // Dropped work is closed, but neither done nor still to do.
335  if (item.kind === 'task') return isDropped(item) ? { done: 0, total: 0 } : { done: item.status === 'done' ? 1 : 0, total: 1 }
336  const tasks = tasksUnder(items, item).filter(task => !isDropped(task))
337  return { done: tasks.filter(task => task.status === 'done').length, total: tasks.length }
338}
339
340/**
341 * What letting go of an item changes: nobody holds it, and a task that was under way goes back to todo,
342 * where `next` and the backlog offer it to the next taker. Blocked and review keep their status.
343 */
344export const letGo = (item: Item): { assignee: null; status?: Status } =>
345  item.kind === 'task' && item.status === 'in_progress' ? { assignee: null, status: 'todo' } : { assignee: null }
346
347/** Whether `who` is an agent: anyone holding work who isn't the person at the board. */
348export const isAgent = (who: string | null | undefined) => Boolean(who) && who !== USER
349
350/**
351 * The milestone or epic above `item` that was handed to an agent as a whole, the outermost when there
352 * are several: the unit of work that is reviewed once, at its end, in place of what is inside it.
353 */
354export function handedScope(items: Item[], item: Item): Item | undefined {
355  // What holds it, inner first: a task's epic, then the milestone it targets (its own, else its epic's).
356  if (item.kind === 'milestone') return undefined
357  const epic = item.kind === 'task' && item.parent ? find(items, item.parent) : undefined
358  const milestone = find(items, targetOf(items, item) ?? undefined)
359  let scope: Item | undefined
360  for (const at of [epic, milestone]) if (at && isAgent(at.assignee)) scope = at
361  return scope
362}
363
364/**
365 * A milestone's or epic's status follows its tasks once it has any; a task's is its own. A milestone or
366 * epic handed to an agent as a whole is reviewed once its tasks are all done: it reads `review` until
367 * the person approves it (its own status set to done), or `in_progress` once they've asked for changes.
368 */
369export function statusOf(items: Item[], item: Item): Status {
370  if (item.kind === 'task') return item.status
371  // Rolled up once per list: the board asks for every row's status, and each roll-up asks for its parts'.
372  const memo = indexOf(items).status
373  let status = memo.get(item)
374  if (status === undefined) memo.set(item, (status = rollUp(items, item)))
375  return status
376}
377
378function rollUp(items: Item[], item: Item): Status {
379  const tasks = tasksUnder(items, item).map(task => task.status)
380  if (tasks.length === 0) return item.status
381  if (tasks.every(status => status === 'done')) {
382    if (isAgent(item.assignee) && item.status !== 'done' && !handedScope(items, item))
383      return item.status === 'in_progress' ? 'in_progress' : 'review'
384    // Not done while a part of it still waits on the person's review.
385    const isPartInReview = childrenOf(items, item.id).some(one => one.kind !== 'task' && statusOf(items, one) === 'review')
386    return isPartInReview ? 'review' : 'done'
387  }
388  if (tasks.includes('blocked')) return 'blocked'
389  if (tasks.some(status => status !== 'todo')) return 'in_progress'
390  return 'todo'
391}
392
393const byId = (a: Item, b: Item) =>
394  KINDS.indexOf(a.kind) - KINDS.indexOf(b.kind) ||
395  (a.due ?? '9999').localeCompare(b.due ?? '9999') ||
396  Number(a.id.slice(1)) - Number(b.id.slice(1))
397
398/** Rows of the tree under `root` (the whole roadmap when absent), depth-first. */
399export function rows(items: Item[], root: string | null = null): { item: Item; depth: number }[] {
400  const out: { item: Item; depth: number }[] = []
401  const walk = (parent: string | null, depth: number) => {
402    for (const item of childrenOf(items, parent).sort(byId)) {
403      out.push({ item, depth })
404      walk(item.id, depth + 1)
405    }
406  }
407  walk(root, 0)
408  return out
409}
410
411/** The item's ancestors, root first, as `M1 v1 launch › E2 Billing`. */
412export const path = (items: Item[], item: Item): string => {
413  const chain: Item[] = []
414  for (let at = find(items, upOf(item) ?? undefined); at; at = find(items, upOf(at) ?? undefined)) chain.unshift(at)
415  return chain.map(one => `${one.id} ${one.title}`).join(' › ')
416}
417
418export function line(items: Item[], item: Item): string {
419  const p = progress(items, item)
420  const status = statusOf(items, item)
421  const bits = [
422    ...marks(item),
423    ...(item.labels ?? []).map(one => `#${one}`),
424    item.kind !== 'task' && p.total > 0 ? `${p.done}/${p.total} tasks` : '',
425    item.assignee ? `@${item.assignee}` : '',
426    item.due ? `due ${item.due}` : '',
427    item.checklist?.length ? `${item.checklist.filter(c => c.done).length}/${item.checklist.length} checked` : '',
428    waitingOn(items, item).length ? `waiting on ${waitingOn(items, item).map(one => one.id).join(', ')}` : '',
429  ]
430    .filter(Boolean)
431    .join(', ')
432  const state = isDropped(item) ? `${WONTDO_GLYPH} won't do` : `${GLYPH[status]} ${status}`
433  return `${item.id} ${state} ${item.title}${bits ? `  (${bits})` : ''}`
434}
435
436export function outline(items: Item[], root: string | null = null): string {
437  return rows(items, root)
438    .map(({ item, depth }) => '  '.repeat(depth) + line(items, item))
439    .join('\n')
440}
441
442/**
443 * The work under a milestone or epic, as an agent starts it: each open task with its description and
444 * checklist beneath its line, finished ones a line each. One show then holds the whole unit.
445 */
446export function workOutline(items: Item[], root: string): string {
447  return rows(items, root)
448    .map(({ item, depth }) => {
449      const pad = '  '.repeat(depth)
450      const head = pad + line(items, item)
451      if (item.kind !== 'task' || item.status === 'done') return head
452      const more = [
453        ...(item.description?.trim() ? item.description.trim().split('\n').map(text => `${pad}    ${text}`) : []),
454        ...item.checklist.map(c => `${pad}    [${c.done ? 'x' : ' '}] ${c.n}. ${c.text}`),
455      ]
456      return [head, ...more].join('\n')
457    })
458    .join('\n')
459}
460
461/**
462 * Comments others left on an item since `reader` last opened it. Status and assignment changes are not
463 * counted: the board already shows them by where the card sits and whose name is on it.
464 */
465export const unread = (snap: Snapshot, id: string, reader: string) =>
466  snap.activity.filter(
467    one => one.item_id === id && one.author !== reader && isMessage(one) && one.id > (snap.seen[id] ?? 0),
468  )
469
470/**
471 * What `who`'s Undo takes back: their latest change still standing, every entry of the write it was
472 * (its op). Undos are passed over, so pressing Undo again walks further back.
473 */
474export function lastChange(snap: Snapshot, who: string): Activity[] {
475  const mine = snap.activity.filter(one => one.author === who && !one.undone && one.type !== 'undo')
476  const newest = mine.reduce<Activity | undefined>((max, one) => (!max || one.id > max.id ? one : max), undefined)
477  // One logged before undo existed can't be taken back, and Undo never skips it for an older one.
478  if (!newest?.undoable) return []
479  return newest.op ? mine.filter(one => one.op === newest.op && one.undoable).sort((a, b) => a.id - b.id) : [newest]
480}
481
482/** Whether an entry is something someone wrote (a comment or a handoff note), not a change the tracker logged. */
483export const isMessage = (one: Activity) => one.type === 'comment' || one.type === 'handoff'
484
485export const timeline = (activity: Activity[], id: string) =>
486  activity.filter(one => one.item_id === id).sort((a, b) => a.id - b.id)
487
488export function detail(snap: Snapshot, item: Item, limit = 15, refs?: Refs): string {
489  const parts = [line(snap.items, item)]
490  const where = path(snap.items, item)
491  if (where) parts.push(`in: ${where}`)
492  const shipped = shipNote(snap, item, refs)
493  if (shipped) parts.push(shipped.charAt(0).toUpperCase() + shipped.slice(1))
494  // Whoever picks the task up reads the last holder's note before anything else.
495  const handoff = timeline(snap.activity, item.id).filter(one => one.type === 'handoff').at(-1)
496  if (handoff) parts.push(`Handoff from ${handoff.author} (${handoff.at.slice(0, 16).replace('T', ' ')}):\n  ${handoff.body}`)
497  if (item.description) parts.push(item.description)
498  if (item.checklist?.length)
499    parts.push('Checklist:\n' + item.checklist.map(c => `  [${c.done ? 'x' : ' '}] ${c.n}. ${c.text}`).join('\n'))
500  const before = (item.blocked_by ?? []).map(id => find(snap.items, id)).filter((one): one is Item => one !== undefined)
501  if (before.length) parts.push('Blocked by:\n' + before.map(one => `  ${line(snap.items, one)}`).join('\n'))
502  const after = blocks(snap.items, item)
503  if (after.length) parts.push('Blocks:\n' + after.map(one => `  ${line(snap.items, one)}`).join('\n'))
504  const links = linksOf(snap.items, item)
505  const named = (ids: string[]) => ids.map(id => find(snap.items, id)).filter((one): one is Item => one !== undefined)
506  if (links.duplicateOf.length) parts.push('Duplicate of:\n' + named(links.duplicateOf).map(one => `  ${line(snap.items, one)}`).join('\n'))
507  if (links.duplicatedBy.length) parts.push('Duplicated by:\n' + named(links.duplicatedBy).map(one => `  ${line(snap.items, one)}`).join('\n'))
508  if (links.relates.length) parts.push('Related:\n' + named(links.relates).map(one => `  ${line(snap.items, one)}`).join('\n'))
509  const under = item.kind === 'task' ? '' : workOutline(snap.items, item.id)
510  if (under) parts.push(under)
511  const log = timeline(snap.activity, item.id).slice(-limit)
512  if (log.length) parts.push('Activity:\n' + log.map(one => `  ${one.at.slice(0, 16).replace('T', ' ')} ${one.author}: ${one.body}`).join('\n'))
513  return parts.join('\n')
514}
515
516/**
517 * The backlog to triage: todo tasks nobody holds, those filed under no milestone or epic first (they
518 * still need a home), then by priority, then oldest first.
519 */
520export const backlog = (items: Item[]): Item[] =>
521  items
522    .filter(item => item.kind === 'task' && item.status === 'todo' && !item.assignee)
523    .sort((a, b) => Number(upOf(a) !== null) - Number(upOf(b) !== null) || byPriority(a, b) || Number(a.id.slice(1)) - Number(b.id.slice(1)))
524
525/**
526 * What to work on next for `actor`: their own open tasks (those still waiting on others last), then
527 * unassigned todo tasks that wait on nothing unfinished, by priority, then due date, then others'
528 * claims gone stale (given `now`), which a claim takes over.
529 */
530export function nextUp(items: Item[], actor: string, now?: number): Item[] {
531  const tasks = items.filter(item => item.kind === 'task')
532  const mine = tasks.filter(task => task.assignee === actor && task.status !== 'done')
533  const due = (task: Item) => dueOf(items, task) ?? '9999'
534  const isWaiting = (task: Item) => waitingOn(items, task).length > 0
535  const free = tasks
536    .filter(task => !task.assignee && task.status === 'todo' && !isWaiting(task))
537    .sort((a, b) => byPriority(a, b) || due(a).localeCompare(due(b)) || byId(a, b))
538  // Work in review waits on the user, so it comes after everything an agent can move on itself.
539  const rank: Record<Status, number> = { in_progress: 0, todo: 1, blocked: 2, review: 3, done: 4 }
540  const stale = now === undefined ? [] : tasks.filter(task => task.assignee !== actor && isStale(task, now)).sort(byPriority)
541  return [
542    ...mine.sort((a, b) => Number(isWaiting(a)) - Number(isWaiting(b)) || rank[a.status] - rank[b.status] || byPriority(a, b)),
543    ...free,
544    ...stale,
545  ]
546}
547
548/**
549 * The task in a milestone or epic for `actor` to start next: one they already have under way, else
550 * the first free todo task that waits on nothing unfinished, as next orders them; never `besides`.
551 */
552export function readyIn(items: Item[], unit: Item, actor: string, besides?: string): Item | undefined {
553  const under = new Set(tasksIn(items, unit).map(one => one.id).filter(id => id !== besides))
554  return nextUp(items, actor).find(
555    task => under.has(task.id) && (task.status === 'todo' || task.status === 'in_progress') && !waitingOn(items, task).length,
556  )
557}
558
559/** The date of a clock reading, as due dates are written (YYYY-MM-DD). */
560export const dateOf = (now: number) => new Date(now).toISOString().slice(0, 10)
561
562/** When an item is due: its own date, else the nearest one above it. */
563export function dueOf(items: Item[], item: Item): string | undefined {
564  // Its own date, else its epic's, else the milestone it targets.
565  if (item.due) return item.due
566  const epic = item.kind === 'task' && item.parent ? find(items, item.parent) : undefined
567  if (epic?.due) return epic.due
568  return item.kind === 'milestone' ? undefined : find(items, targetOf(items, item) ?? undefined)?.due ?? undefined
569}
570
571/**
572 * Where a milestone or epic sits on the roadmap's time axis: from its start, given or derived, to its
573 * end, its due date (an epic's, else its milestone's). An epic without a start begins at its first claim
574 * (the earliest claim or status change on its tasks), else when it was made; a milestone at the earliest
575 * of what targets it, else when it was made. `isStartGiven` says which.
576 */
577export function spanOf(snap: Snapshot, item: Item): { start: string; end?: string; isStartGiven: boolean } {
578  const items = snap.items
579  const end = dueOf(items, item)
580  if (item.start) return { start: item.start, end, isStartGiven: true }
581  const made = item.created_at.slice(0, 10)
582  if (item.kind === 'milestone') {
583    const parts = items.filter(one => one.kind === 'epic' && targetOf(items, one) === item.id).map(one => spanOf(snap, one).start)
584    const loose = tasksIn(items, item).filter(one => !one.parent).map(one => one.created_at.slice(0, 10))
585    const first = [...parts, ...loose].sort()[0]
586    return { start: first ?? made, end, isStartGiven: false }
587  }
588  const ids = new Set(tasksIn(items, item).map(one => one.id))
589  const begun = snap.activity
590    .filter(one => ids.has(one.item_id) && (one.type === 'status' || (one.type === 'assign' && one.body === 'claimed')))
591    .map(one => one.at.slice(0, 10))
592    .sort()[0]
593  return { start: begun ?? made, end, isStartGiven: false }
594}
595
596/** Whether an item is past when it was due (its own date or one above it) and not done; never without a clock. */
597export const isLate = (items: Item[], item: Item, now: number) => {
598  const due = dueOf(items, item)
599  return now > 0 && due !== undefined && due < dateOf(now) && statusOf(items, item) !== 'done'
600}
601
602/** Days from date `a` to date `b` (YYYY-MM-DD): negative when `b` is before `a`. */
603export const daysBetween = (a: string, b: string) => Math.round((Date.parse(b) - Date.parse(a)) / 86_400_000)
604
605/**
606 * The timeline: milestones (and epics under none) by due date, soonest first and undated last, each
607 * milestone followed by its epics in the same order.
608 */
609/** A CHANGELOG's released versions, newest first as written: each with its date and the text under it. */
610export function changelogVersions(text: string): { version: string; date: string; body: string }[] {
611  const lines = text.replace(/\r\n/g, '\n').split('\n')
612  const out: { version: string; date: string; body: string }[] = []
613  lines.forEach((line, i) => {
614    const head = /^## \[?(\d+\.\d+\.\d+)\]?(?: - (\d{4}-\d{2}-\d{2}))?/.exec(line)
615    if (!head) return
616    const next = lines.findIndex((other, j) => j > i && (other.startsWith('## ') || /^\[[^\]]+\]: \S/.test(other)))
617    out.push({ version: head[1]!, date: head[2] ?? '', body: lines.slice(i + 1, next < 0 ? undefined : next).join('\n').trim() })
618  })
619  return out
620}
621
622/** The tasks a release's notes carry: those whose note is in `body`, but not ones `taken` by another release. */
623export function shippedIn(items: Item[], body: string, taken: Set<string> = new Set()): { id: string; note: string; section: Section | null }[] {
624  return items
625    .filter(task => task.kind === 'task' && hasNote(task) && !taken.has(task.id) && body.includes(task.note!))
626    .map(task => ({ id: task.id, note: task.note!, section: sectionFor(task) }))
627}
628
629/** The release a task shipped in, from the record of releases. */
630export const releaseOf = (snap: Snapshot, id: string): Release | undefined => (snap.releases ?? []).find(one => one.tasks.some(task => task.id === id))
631
632/** Merged work no release carries yet: the tasks whose notes the next release would ship. */
633export function unreleased(snap: Snapshot, refs: Refs): Item[] {
634  const shipped = new Set((snap.releases ?? []).flatMap(one => one.tasks.map(task => task.id)))
635  return mergedNotes(snap.items, refs).filter(task => !shipped.has(task.id))
636}
637
638/**
639 * How an item stands against releases, in a few words, or undefined: a task "shipped in vX", or "merged,
640 * not released" (given `refs`); a milestone how many of its noted tasks are out, and in which versions.
641 */
642export function shipNote(snap: Snapshot, item: Item, refs?: Refs): string | undefined {
643  if (item.kind === 'task') {
644    const release = releaseOf(snap, item.id)
645    if (release) return `shipped in v${release.version}`
646    return refs && unreleased(snap, refs).some(one => one.id === item.id) ? 'merged, not released' : undefined
647  }
648  if (item.kind !== 'milestone') return undefined
649  const noted = tasksIn(snap.items, item).filter(hasNote)
650  const out = noted.map(task => releaseOf(snap, task.id)).filter((one): one is Release => one !== undefined)
651  if (out.length === 0) return undefined
652  const versions = [...new Set(out.map(one => one.version))].sort((a, b) => (isAfter(versionOf(a)!, versionOf(b)!) ? 1 : -1))
653  return `${out.length}/${noted.length} shipped (${versions.map(one => `v${one}`).join(', ')})`
654}
655
656/** The version to suggest for the next release after `last`: a patch when it only fixes, else a minor. */
657export function nextVersion(last: string | undefined, notes: Item[]): string {
658  const [major, minor, patch] = versionOf(last) ?? [0, 0, 0]
659  return notes.length > 0 && notes.every(task => sectionFor(task) === 'Fixed') ? `${major}.${minor}.${patch + 1}` : `${major}.${minor + 1}.0`
660}
661
662/** Releases, newest version first. */
663export const releasesOf = (snap: Snapshot): Release[] =>
664  [...(snap.releases ?? [])].sort((a, b) => (isAfter(versionOf(a.version) ?? [0, 0, 0], versionOf(b.version) ?? [0, 0, 0]) ? -1 : 1))
665
666/** Open work first, then what is done, each in the order `list` gives. */
667export const openFirst = (items: Item[], list: Item[]): Item[] => [
668  ...list.filter(one => statusOf(items, one) !== 'done'),
669  ...list.filter(one => statusOf(items, one) === 'done'),
670]
671
672/** The tree's rows: open work first at every level, and none under an item `isFolded` says is folded. */
673export function treeRows(items: Item[], isFolded: (item: Item) => boolean): { item: Item; depth: number }[] {
674  const out: { item: Item; depth: number }[] = []
675  const walk = (parent: string | null, depth: number) => {
676    const level = childrenOf(items, parent).sort(byId)
677    // At the top, milestones come before what no milestone holds, each open first.
678    const ordered = parent === null
679      ? [...openFirst(items, level.filter(one => one.kind === 'milestone')), ...openFirst(items, level.filter(one => one.kind !== 'milestone'))]
680      : openFirst(items, level)
681    for (const item of ordered) {
682      out.push({ item, depth })
683      if (!isFolded(item)) walk(item.id, depth + 1)
684    }
685  }
686  walk(null, 0)
687  return out
688}
689
690/** The timeline's rows: `timelineOf`'s, open work first, a milestone's epics left out while it is folded. */
691export function timelineRows(items: Item[], isFolded: (item: Item) => boolean): { item: Item; depth: number }[] {
692  const all = timelineOf(items)
693  const tops = openFirst(items, all.filter(one => !find(items, upOf(one) ?? undefined)))
694  return tops.flatMap(top => [
695    { item: top, depth: 0 },
696    ...(isFolded(top) ? [] : openFirst(items, all.filter(one => upOf(one) === top.id)).map(item => ({ item, depth: 1 }))),
697  ])
698}
699
700export function timelineOf(items: Item[]): Item[] {
701  const byDue = (list: Item[]) => [...list].sort((a, b) => (a.due ?? '9999').localeCompare(b.due ?? '9999') || byId(a, b))
702  const top = byDue(items.filter(one => one.kind !== 'task' && !find(items, upOf(one) ?? undefined)))
703  return top.flatMap(one => [one, ...(one.kind === 'milestone' ? byDue(childrenOf(items, one.id).filter(child => child.kind === 'epic')) : [])])
704}
705
706/** The roadmap as a short brief for an agent: its own work, what is blocked, and what changed. */
707export function brief(snap: Snapshot, actor: string, news: Activity[], now?: number, refs?: Refs): string | undefined {
708  if (snap.items.length === 0) return undefined
709  const items = snap.items
710  const tasks = items.filter(item => item.kind === 'task')
711  const list = (some: Item[], cap = 8) =>
712    some.slice(0, cap).map(item => `- ${line(items, item)}${upOf(item) ? ` [${upOf(item)}]` : ''}`).join('\n') +
713    (some.length > cap ? `\n- …${some.length - cap} more` : '')
714  const milestones = items.filter(item => item.kind === 'milestone' && statusOf(items, item) !== 'done').sort(byId)
715  const mine = tasks.filter(task => task.assignee === actor && task.status !== 'done' && task.status !== 'review')
716  const blocked = tasks.filter(task => task.status === 'blocked')
717  const review = items.filter(item => statusOf(items, item) === 'review')
718  const stale = now === undefined ? [] : tasks.filter(task => task.assignee !== actor && isStale(task, now))
719  const active = tasks.filter(task => task.status === 'in_progress' && task.assignee !== actor && !stale.includes(task))
720  const parts = [
721    'Project roadmap (roadmap tool). Claim before you start; keep it current as you go.',
722  ]
723  if (milestones.length) parts.push('Open milestones:\n' + list(milestones, 4))
724  if (mine.length) parts.push(`Assigned to you (${actor}):\n` + list(mine))
725  if (active.length) parts.push('In progress by others:\n' + list(active))
726  if (stale.length)
727    parts.push(`Stale claims (holder silent over ${LEASE_MS / 60_000} min; claiming takes one over):\n` + list(stale))
728  if (blocked.length) parts.push('Blocked:\n' + list(blocked))
729  // Dated items past their date: a milestone, epic or task with a due date of its own.
730  const late = now === undefined ? [] : items.filter(item => item.due && isLate(items, item, now)).sort((a, b) => a.due!.localeCompare(b.due!))
731  if (late.length) parts.push(`Overdue (past their due date, not done; today is ${dateOf(now!)}):\n` + list(late))
732  if (review.length) {
733    // Each with its pull request, or a note that it still needs one.
734    const pr = (item: Item) => {
735      if (!refs) return ''
736      const open = refs.prs.find(one => one.state === 'open' && one.ids.includes(item.id))
737      return open ? ` — PR #${open.number} ${open.url}` : ' — no PR yet'
738    }
739    parts.push(
740      "Waiting on the user's review (they approve on the board, or tell you to):\n" +
741        review.slice(0, 8).map(item => `- ${line(items, item)}${pr(item)}`).join('\n'),
742    )
743  }
744  // What a release would ship now: said so a "release" from the user finds it in hand.
745  const waiting = refs ? unreleased(snap, refs) : []
746  if (waiting.length) parts.push(`Merged, not released yet: ${waiting.slice(0, 8).map(one => one.id).join(', ')}${waiting.length > 8 ? ` and ${waiting.length - 8} more` : ''}.`)
747  if (news.length)
748    parts.push(
749      'Changes by the user since you last looked:\n' +
750        news.slice(-10).map(one => `- ${one.item_id}: ${one.body}`).join('\n'),
751    )
752  return `<roadmap>\n${parts.join('\n\n')}\n</roadmap>`
753}
754
755/**
756 * The turn that tells the agent who did `item` how the person's Approve on the board went: merged (or
757 * approved without a merge), so it brings the checkout up to date; or a merge that failed, so it finds out why.
758 */
759export function approvalNote(item: Item, pr: Pr | undefined, failure?: string): string {
760  const what = `roadmap ${item.kind} ${item.id} (${item.title})`
761  if (pr && failure !== undefined)
762    return (
763      `The user approved ${what} on the board, but merging PR #${pr.number} (branch ${pr.branch}) failed: ${failure || 'no reason given'}. ` +
764      `${item.id} stays in review. Find out why (failing checks, a conflict with its base, branch protection), fix what you can on ${pr.branch}, ` +
765      'and tell the user what you found and whether it is ready to approve again.'
766    )
767  if (pr && pr.base && !isMainLine(pr.base))
768    return (
769      `The user approved ${what} on the board and merged PR #${pr.number} (branch ${pr.branch}) into ${pr.base}, not into main. ` +
770      `Its work reaches main only when ${pr.base} does. Pull ${pr.base}, delete the local branch ${pr.branch}, keep the checkout on the ` +
771      'top of what is still open (the user runs the mod from it), and say what is left to merge, in order.'
772    )
773  if (pr)
774    return (
775      `The user approved ${what} on the board and merged PR #${pr.number} (branch ${pr.branch}) into ${pr.base || 'main'}. Bring the checkout up to date: ` +
776      `switch to ${pr.base || 'main'} and pull, delete the local branch ${pr.branch}, and make sure any open PR that was based on ${pr.branch} now targets ${pr.base || 'main'}. ` +
777      'Then say in a line or two what is next on the roadmap.'
778    )
779  return `The user approved ${what} on the board; it is done. No pull request was merged with it. Say in a line or two what is next on the roadmap.`
780}
781
782/** The agent type a task run in parallel goes to, and the most such agents working at once. */
783export const WORKER_TYPE = 'general-purpose'
784export const WORKERS_MAX = 4
785
786/** What a parallel task's agent is spawned as: its task, by id and title. */
787export const workerTask = (task: Item) => `${task.id} ${task.title}`
788
789/** The name a parallel task's agent goes by on the board, as its own calls will be named. */
790export const workerName = (task: Item) => agentName(WORKER_TYPE, workerTask(task))
791
792/** The first turn of a parallel task's agent, which starts in the worktree made for it, on its branch. */
793export function workerPrompt(task: Item, branch: string, dir: string): string {
794  return [
795    `You are working roadmap task ${task.id}: ${task.title}. The user handed out several tasks to run at once, each to its own agent in its own git worktree.`,
796    `Your worktree is ${dir}, already on branch ${branch}, made from the main line. Work only there; other agents work in the other worktrees.`,
797    `1. Claim the task with the roadmap tool (claim ${task.id}); it is assigned to you, and the claim starts it. Read what it asks (show ${task.id}).`,
798    '2. Do the work. Tick its checklist as each criterion is met (check), and comment on decisions and findings.',
799    `3. Commit as "${task.id}: ...". If the repository has a remote, push the branch and open its pull request (pr ${task.id} gives the title and body; base it on the main line).`,
800    `4. Set ${task.id} done with its release note (note, section). It goes to the user's review.`,
801    `If you cannot finish, release ${task.id} with a handoff note saying where you got to. Don't remove the worktree.`,
802  ].join('\n')
803}
804
805/** A parallel task's prompt, recognised as the main loop's Agent call passes it on: the task and its worktree. */
806export function workerOf(prompt: string): { id: string; dir: string } | undefined {
807  const found = /^You are working roadmap task (\w+):[\s\S]*?\nYour worktree is (.+?), already on branch /.exec(prompt)
808  return found ? { id: found[1]!, dir: found[2]! } : undefined
809}
810
811/**
812 * The turn asking the main loop to start parallel tasks: agents a plugin spawns can't call the plugin's
813 * own tool, so the main loop's Agent tool starts them, each prompt passed on as written.
814 */
815export function workersNote(work: { task: Item; prompt: string }[]): string {
816  return [
817    `The user handed out roadmap tasks to run at once from the board: ${work.map(one => one.task.id).join(', ')}. Start each now as its own background agent: ` +
818      `one Agent call per task, all in this one message, subagent_type "${WORKER_TYPE}", run_in_background true, the description given, and the prompt exactly as written ` +
819      '(its worktree and branch are made). Then say in a line which started; their progress shows on the board.',
820    ...work.map(one => `--- ${one.task.id}\ndescription: ${workerTask(one.task)}\nprompt:\n${one.prompt}`),
821  ].join('\n\n')
822}
823
824/** The prompt Ask Claude puts in the box for the person to finish: which item, by id and title. */
825export const askAbout = (item: Item) => `About roadmap ${item.kind} ${item.id} (${item.title}): `
826
827/** The turn a comment starts when the person sends it to the agent holding the item. */
828export function commentNote(item: Item, body: string): string {
829  const what = `roadmap ${item.kind} ${item.id} (${item.title})`
830  return item.assignee === CLAUDE
831    ? `The user commented on ${what}, which you hold: "${body}". Read it with the roadmap tool (show ${item.id}) and act on it, commenting back there.`
832    : `The user commented on ${what}, which ${item.assignee} holds: "${body}". If ${item.assignee} is still running, pass it on (SendMessage); otherwise act on it yourself. Comment back on ${item.id}.`
833}
834
835/** Lowercase words joined by hyphens, cut at a word boundary to at most `cap` characters. */
836function slug(text: string, cap: number): string {
837  const full = text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '')
838  if (full.length <= cap) return full
839  const cut = full.slice(0, cap + 1).lastIndexOf('-')
840  return cut > 0 ? full.slice(0, cut) : full.slice(0, cap)
841}
842
843/**
844 * What `item` ships in: the milestone or epic it was handed over inside, or the item itself. One unit
845 * of work, one review, one branch and one pull request.
846 */
847export const unitOf = (items: Item[], item: Item): Item => handedScope(items, item) ?? item
848
849/** The branch a unit of work is built on: its id and title, as `e9-agent-coordination`. */
850export const branchFor = (item: Item) => `${item.id.toLowerCase()}-${slug(item.title, 40)}`.replace(/-$/, '')
851
852/** A unit's pull request: titled with its id, the body listing what was done and what done meant. */
853export function pullRequest(items: Item[], item: Item): { branch: string; title: string; body: string } {
854  const tasks = item.kind === 'task' ? [item] : tasksIn(items, item)
855  const parts: string[] = []
856  if (item.description) parts.push(item.description)
857  parts.push(
858    tasks
859      .map(task => {
860        if (isDropped(task)) return `- **${task.id}** ~~${task.title}~~ (won't do)`
861        const head = item.kind === 'task' ? '' : `- **${task.id}** ${task.title}\n`
862        const pad = item.kind === 'task' ? '' : '  '
863        return head + task.checklist.map(c => `${pad}- [${c.done ? 'x' : ' '}] ${c.text}`).join('\n')
864      })
865      .join('\n')
866      .trim(),
867  )
868  // The tasks' release notes, as they will read in the CHANGELOG.
869  const noted = tasks.filter(hasNote)
870  if (noted.length)
871    parts.push(['### Release notes', ...SECTIONS.flatMap(section => {
872      const some = noted.filter(task => sectionFor(task) === section)
873      return some.length ? ['', `${section}:`, ...some.map(task => `- ${task.note} (${task.id})`)] : []
874    })].join('\n'))
875  parts.push(`Tracked on the roadmap as ${item.id}${upOf(item) ? `, in ${path(items, item)}` : ''}.`)
876  return { branch: branchFor(item), title: `${item.id}: ${item.title}`, body: parts.filter(Boolean).join('\n\n') }
877}
878
879/**
880 * A subagent's name on the board, stable for its whole run: a teammate's own name, else its type and
881 * task (`explore:find-auth-handlers`). Two agents of one type on one task share it, as they share the work.
882 */
883export function agentName(type: string, description: string, teammateId?: string): string {
884  if (teammateId) return teammateId.split('@')[0] || teammateId
885  const task = slug(description, 32)
886  return task ? `${slug(type, 24) || 'agent'}:${task}` : slug(type, 24) || 'agent'
887}
888
889/**
890 * Roadmap ids a text names, as written in a commit, a PR title or a branch: `T12`, `[T12]`, `E9:`,
891 * `M4`, `e9-agent-coordination`. A word that only starts like one (`e2e`, `t3a`) is not one.
892 */
893export function idsIn(text: string): string[] {
894  const found = (text.match(/\b[TtEeMm]\d+\b/g) ?? []).map(id => id.toUpperCase())
895  return [...new Set(found)]
896}
897
898/** Commits from `git log --format=%h%x1f%an%x1f%as%x1f%B%x1e` that name a task id. */
899export function parseGitLog(out: string): Commit[] {
900  return out
901    .split('\x1e')
902    .map(record => record.replace(/^\n+/, '').split('\x1f'))
903    .filter(fields => fields.length >= 4)
904    .map(([hash, author, date, body]) => ({
905      hash: hash!, author: author!, date: date!, subject: body!.trim().split('\n')[0] ?? '', ids: idsIn(body!),
906    }))
907    .filter(commit => commit.ids.length > 0)
908}
909
910type CheckEntry = { status?: string; conclusion?: string; state?: string }
911
912/** A rollup of check runs and status contexts as one word: a failure wins, then anything unfinished. */
913export function checksOf(rollup: CheckEntry[] | null | undefined): Checks {
914  const list = rollup ?? []
915  if (list.length === 0) return 'none'
916  const outcome = (one: CheckEntry) => (one.conclusion || one.state || '').toUpperCase()
917  if (list.some(one => ['FAILURE', 'ERROR', 'CANCELLED', 'TIMED_OUT', 'ACTION_REQUIRED', 'STARTUP_FAILURE'].includes(outcome(one)))) return 'fail'
918  if (list.some(one => (one.status && one.status.toUpperCase() !== 'COMPLETED') || ['PENDING', 'EXPECTED', ''].includes(outcome(one)))) return 'pending'
919  return 'pass'
920}
921
922/** Pull requests from `gh pr list --json number,title,headRefName,baseRefName,state,url,statusCheckRollup` that name a roadmap id. */
923export function parsePrs(out: string): Pr[] {
924  const list = JSON.parse(out) as { number: number; title: string; headRefName: string; baseRefName?: string; state: string; url: string; statusCheckRollup?: CheckEntry[] }[]
925  return list
926    .map(pr => ({
927      number: pr.number, title: pr.title, state: pr.state.toLowerCase(), url: pr.url,
928      ids: idsIn(`${pr.title} ${pr.headRefName}`), checks: checksOf(pr.statusCheckRollup), branch: pr.headRefName, base: pr.baseRefName ?? '',
929    }))
930    // Those naming items, and release PRs, which the record of releases names.
931    .filter(pr => pr.ids.length > 0 || pr.branch.startsWith('release-v'))
932}
933
934/** The open pull request a unit of work ships in: the one naming the unit itself. */
935export const openPrOf = (refs: Refs, item: Item): Pr | undefined =>
936  refs.prs.find(pr => pr.state === 'open' && pr.ids.includes(item.id))
937
938/**
939 * The open pull request `pr` is stacked on: the one whose branch it merges into. Merging `pr` first would
940 * land it in that branch, not in main, so that one goes first.
941 */
942export const stackedOn = (refs: Refs, pr: Pr): Pr | undefined =>
943  pr.base ? refs.prs.find(one => one.state === 'open' && one.number !== pr.number && one.branch === pr.base) : undefined
944
945/**
946 * The stack `pr` is the bottom of: it, then each open PR based on the branch of the one before (the
947 * lowest-numbered where two are), up to the top. Just `[pr]` when nothing is stacked on it, or when it
948 * is itself stacked on another open PR (only a stack's bottom merges it).
949 */
950export function stackFrom(refs: Refs, pr: Pr): Pr[] {
951  if (stackedOn(refs, pr)) return [pr]
952  const out = [pr]
953  for (;;) {
954    const top = out.at(-1)!
955    const next = refs.prs
956      .filter(one => one.state === 'open' && one.base === top.branch && !out.includes(one))
957      .sort((a, b) => a.number - b.number)[0]
958    if (!next) return out
959    out.push(next)
960  }
961}
962
963/**
964 * What merging `pr` takes when it is stacked: the open PRs beneath it, bottom first, then it. Each is
965 * stacked on the next one down; those above `pr` are not part of it. Just `[pr]` when it stands on its own.
966 */
967export function stackBelow(refs: Refs, pr: Pr): Pr[] {
968  const out = [pr]
969  for (let under = stackedOn(refs, pr); under && !out.includes(under); under = stackedOn(refs, under)) out.unshift(under)
970  return out
971}
972
973/** A stack as the card shows it: `#11 ← #12 ← #15`, bottom first. */
974export const stackText = (stack: Pr[]) => stack.map(pr => `#${pr.number}`).join(' ← ')
975
976/**
977 * The turn telling Claude how merging a stack from the board went: all merged into `base`, so it brings
978 * the checkout up to date; or stopped at a PR, with why, so it finds out and fixes what it can.
979 */
980export function stackNote(stack: Pr[], merged: Pr[], base: string, failure?: { at: Pr; why: string }): string {
981  const done = merged.length ? `merged ${merged.map(pr => `#${pr.number} (${pr.branch})`).join(', ')} into ${base}` : 'merged none of it'
982  if (failure)
983    return (
984      `The user merged the stack ${stackText(stack)} from the board, bottom first: it ${done}, then stopped at PR #${failure.at.number} ` +
985      `(branch ${failure.at.branch}): ${failure.why}. What is left stays in review. Find out why (failing checks on ${base}, a conflict, branch protection), ` +
986      `fix what you can on ${failure.at.branch}, bring the checkout up to date with ${base}, and tell the user whether the rest is ready to merge.`
987    )
988  return (
989    `The user merged the stack ${stackText(stack)} from the board: ${done}, each after its checks passed on ${base}; their items are approved. ` +
990    `Bring the checkout up to date: switch to ${base} and pull, delete the local branches ${stack.map(pr => pr.branch).join(', ')}, ` +
991    'then say in a line or two what is next on the roadmap.'
992  )
993}
994
995/** Whether a branch is the repository's main line, where a merged pull request's work is done. */
996export const isMainLine = (branch: string) => branch === 'main' || branch === 'master'
997
998/**
999 * The commits and pull requests that name `item` or anything under it; and the pull requests of what
1000 * it sits in, since a task handed over inside an epic ships in the epic's PR.
1001 */
1002export function refsFor(items: Item[], refs: Refs, item: Item): Refs {
1003  const ids = new Set(subtree(items, item.id))
1004  const above = new Set<string>()
1005  for (let at = find(items, upOf(item) ?? undefined); at; at = find(items, upOf(at) ?? undefined)) above.add(at.id)
1006  return {
1007    commits: refs.commits.filter(commit => commit.ids.some(id => ids.has(id))),
1008    prs: refs.prs.filter(pr => pr.ids.some(id => ids.has(id) || above.has(id))),
1009  }
1010}
1011
1012export function refsText(found: Refs, limit = 8): string {
1013  const parts: string[] = []
1014  if (found.prs.length)
1015    parts.push('Pull requests:\n' + found.prs.slice(0, limit).map(pr => `  #${pr.number} [${pr.state}] ${pr.title}  ${pr.url}`).join('\n'))
1016  if (found.commits.length)
1017    parts.push(
1018      'Commits:\n' +
1019        found.commits.slice(0, limit).map(c => `  ${c.hash} ${c.date} ${c.author}: ${c.subject}`).join('\n') +
1020        (found.commits.length > limit ? `\n  …${found.commits.length - limit} more` : ''),
1021    )
1022  return parts.join('\n')
1023}
1024
1025/** What git says of the database's path: ignored, tracked-or-not-ignored, or no repository here. */
1026export type IgnoreState = 'ignored' | 'not-ignored' | 'no-repo'
1027
1028/** `git check-ignore -q` exits 0 for an ignored path, 1 for one that is not, 128 outside a repository. */
1029export const ignoreState = (exitCode: number): IgnoreState =>
1030  exitCode === 0 ? 'ignored' : exitCode === 1 ? 'not-ignored' : 'no-repo'
1031
1032/** Where the offer stands, per project: absent until first made, `told` until the person answers it. */
1033export type IgnoreAnswer = 'told' | 'added' | 'dismissed'
1034
1035/** The offer stands only in a repository that doesn't ignore the database, to someone who hasn't turned it down. */
1036export const shouldOfferIgnore = (state: IgnoreState, answer: IgnoreAnswer | undefined) =>
1037  state === 'not-ignored' && answer !== 'dismissed'
1038
1039export const IGNORE_LINE = '.claude/roadmap.db*'
1040
1041/** `dir` and the folders above it, nearest first: /a/b gives /a/b, /a, /. */
1042export function ancestors(dir: string): string[] {
1043  const parts = dir.replace(/\/+$/, '').split('/')
1044  return parts.map((_, i) => parts.slice(0, parts.length - i).join('/') || '/')
1045}
1046
1047/** Why a write found no roadmap to make here, and where to go instead. */
1048export function noRoadmapHere(dir: string, found: string[]): string {
1049  const why = `No roadmap here, and none was started: ${dir} is not the top of a git repository, so a new one would be in the wrong place.`
1050  if (found.length === 0)
1051    return `${why} Start the session in the project's folder (its git top level; git init it first if it has none) and the first write starts its roadmap there.`
1052  return `${why} Roadmaps found below it: ${found.join(', ')}. Start the session in the project's folder (cd there and run claude) to use its roadmap.`
1053}
1054
1055/** A .gitignore's text with the database's line appended, on a line of its own. */
1056export function withIgnore(text: string | undefined): string {
1057  const base = text ?? ''
1058  const gap = base === '' || base.endsWith('\n') ? '' : '\n'
1059  return `${base}${gap}# The roadmap tracker's database (binary, per checkout).\n${IGNORE_LINE}\n`
1060}
1061
1062/**
1063 * The release notes of merged work: done tasks with a note whose unit of work has no open pull request,
1064 * and has a merged one, or none at all and is done (work committed straight to the main line).
1065 */
1066export function mergedNotes(items: Item[], refs: Refs): Item[] {
1067  return rows(items)
1068    .map(row => row.item)
1069    .filter(task => task.kind === 'task' && task.status === 'done' && hasNote(task))
1070    .filter(task => {
1071      const unit = unitOf(items, task)
1072      const prs = refs.prs.filter(pr => pr.ids.includes(unit.id))
1073      if (prs.some(pr => pr.state === 'open')) return false
1074      // Merged by its PR; or, with none, once its whole unit is done (committed straight to the main line),
1075      // not while a milestone or epic it ships in is still under way on its branch.
1076      return prs.some(pr => pr.state === 'merged') || (prs.length === 0 && statusOf(items, unit) === 'done')
1077    })
1078    // Newest first, as a CHANGELOG reads.
1079    .sort((a, b) => b.updated_at.localeCompare(a.updated_at) || Number(b.id.slice(1)) - Number(a.id.slice(1)))
1080}
1081
1082/**
1083 * A CHANGELOG's text with `notes` added under `## [Unreleased]`, each in its section, newest first, as
1084 * Keep a Changelog lays it out; the heading and sections are made when missing. A note already in the
1085 * file is left out. Answers the text and the notes that went in.
1086 */
1087export function withNotes(text: string | undefined, notes: { section: Section; note: string }[]): { text: string; added: string[] } {
1088  const before = text ?? ''
1089  const fresh = notes.filter((one, i) => !before.includes(one.note) && notes.findIndex(other => other.note === one.note) === i)
1090  if (fresh.length === 0) return { text: before, added: [] }
1091  const lines = (before || '# Changelog\n').replace(/\r\n/g, '\n').split('\n')
1092  let start = lines.findIndex(line => /^## \[?unreleased\]?/i.test(line))
1093  if (start < 0) {
1094    // Above the newest version, set off by blank lines.
1095    const first = lines.findIndex(line => line.startsWith('## '))
1096    let at = first < 0 ? lines.length : first
1097    while (at > 0 && lines[at - 1]!.trim() === '') at--
1098    lines.splice(at, 0, '', '## [Unreleased]', ...(first < 0 ? [] : ['']))
1099    start = at + 1
1100    while (start + 2 < lines.length && lines[start + 2]!.trim() === '' && lines[start + 1]!.trim() === '') lines.splice(start + 2, 1)
1101  }
1102  for (const section of SECTION_ORDER.filter(one => fresh.some(note => note.section === one))) {
1103    const bullets = fresh.filter(one => one.section === section).map(one => `- ${one.note}`)
1104    const next = lines.findIndex((line, i) => i > start && line.startsWith('## '))
1105    const end = next < 0 ? lines.length : next
1106    const head = lines.findIndex((line, i) => i > start && i < end && line.trim() === `### ${section}`)
1107    if (head >= 0) {
1108      let at = head + 1
1109      while (at < end && lines[at]!.trim() === '') at++
1110      if (at < end && lines[at]!.startsWith('- ')) lines.splice(at, 0, ...bullets)
1111      else lines.splice(head + 1, 0, '', ...bullets)
1112      continue
1113    }
1114    const later = lines.findIndex((line, i) => i > start && i < end && line.startsWith('### ') &&
1115      SECTION_ORDER.indexOf(line.slice(4).trim()) > SECTION_ORDER.indexOf(section))
1116    if (later >= 0) lines.splice(later, 0, `### ${section}`, '', ...bullets, '')
1117    else {
1118      let at = end
1119      while (at - 1 > start && lines[at - 1]!.trim() === '') at--
1120      const block = ['', `### ${section}`, '', ...bullets]
1121      lines.splice(at, 0, ...block)
1122      // A blank line between it and whatever follows (the next version's heading).
1123      const after = at + block.length
1124      if (after < lines.length && lines[after]!.trim() !== '') lines.splice(after, 0, '')
1125    }
1126  }
1127  return { text: lines.join('\n'), added: fresh.map(one => one.note) }
1128}
1129
1130/** A version as `[major, minor, patch]`, from `1.2.3` or `v1.2.3`; undefined when it is not one. */
1131export function versionOf(text: string | undefined): [number, number, number] | undefined {
1132  const found = /^v?(\d+)\.(\d+)\.(\d+)$/.exec(text?.trim() ?? '')
1133  return found ? [Number(found[1]), Number(found[2]), Number(found[3])] : undefined
1134}
1135
1136/** Whether version `a` comes after `b`. */
1137export const isAfter = (a: [number, number, number], b: [number, number, number]) => (a[0] - b[0] || a[1] - b[1] || a[2] - b[2]) > 0
1138
1139/** A manifest's text (plugin.json, package.json) with its version set, the rest as it was; undefined when it has none. */
1140export function withVersion(text: string, version: string): string | undefined {
1141  const pattern = /("version"\s*:\s*")([^"]*)(")/
1142  return pattern.test(text) ? text.replace(pattern, `$1${version}$3`) : undefined
1143}
1144
1145/** The repository's web address from a git remote (`git@github.com:o/r.git`, `https://github.com/o/r.git`). */
1146export const webOf = (remote: string) =>
1147  remote.trim().replace(/^git@([^:]+):/, 'https://$1/').replace(/\.git$/, '').replace(/\/+$/, '')
1148
1149/**
1150 * A CHANGELOG with its [Unreleased] section cut as `version`, dated `date`, under a fresh empty
1151 * [Unreleased], and its links pointing [Unreleased] at what comes after the version's tag. Answers the
1152 * text and the version's notes (what [Unreleased] held), or throws when there is nothing to release.
1153 */
1154export function cutRelease(text: string, version: string, date: string, web: string): { text: string; notes: string } {
1155  const lines = text.replace(/\r\n/g, '\n').split('\n')
1156  const start = lines.findIndex(line => /^## \[?unreleased\]?/i.test(line))
1157  if (start < 0) throw new Error('the CHANGELOG has no [Unreleased] section to release')
1158  const next = lines.findIndex((line, i) => i > start && line.startsWith('## '))
1159  const links = lines.findIndex((line, i) => i > start && /^\[[^\]]+\]: \S/.test(line))
1160  const end = next >= 0 ? next : links >= 0 ? links : lines.length
1161  const notes = lines.slice(start + 1, end).join('\n').trim()
1162  if (!notes) throw new Error('nothing is under [Unreleased] in the CHANGELOG; there is nothing to release')
1163  lines.splice(start, 1, '## [Unreleased]', '', `## [${version}] - ${date}`)
1164  // The links: [Unreleased] now compares against this version's tag, which gets one of its own.
1165  const ours = [`[Unreleased]: ${web}/compare/v${version}...HEAD`, `[${version}]: ${web}/releases/tag/v${version}`]
1166  const old = lines.findIndex(line => /^\[unreleased\]: /i.test(line))
1167  if (old >= 0) lines.splice(old, 1, ...ours)
1168  else {
1169    while (lines.length && lines.at(-1)!.trim() === '') lines.pop()
1170    lines.push('', ...ours, '')
1171  }
1172  return { text: lines.join('\n'), notes }
1173}
1174
hooks/db.ts 656 lines
1import type { Check, IssueType, Item, Kind, Priority, Relation, Release, Snapshot, Status } from '../types'
2import { PREFIX } from './model'
3
4export const DB = '.claude/roadmap.db'
5const NOW = `strftime('%Y-%m-%dT%H:%M:%SZ','now')`
6
7/**
8 * The schema's history: MIGRATIONS[i] takes a database from version i to i + 1, and the version a
9 * database is at lives in its own `PRAGMA user_version`. Append to this list; never edit an entry that
10 * has shipped. A database made before versioning (the tables there, version 0) runs entry 0 harmlessly,
11 * every statement of it being IF NOT EXISTS.
12 */
13/** Moves epics and tasks parented to a milestone onto it as their target (v7, and imports from before). */
14const RETARGET = `UPDATE items SET milestone=parent, parent=NULL WHERE kind IN ('epic', 'task') AND parent IN (SELECT id FROM items WHERE kind='milestone');`
15
16export const MIGRATIONS: string[] = [
17  `CREATE TABLE IF NOT EXISTS items(
18  id TEXT PRIMARY KEY, kind TEXT NOT NULL, title TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'todo',
19  parent TEXT, description TEXT, assignee TEXT, due TEXT,
20  created_at TEXT NOT NULL DEFAULT (${NOW}), updated_at TEXT NOT NULL DEFAULT (${NOW}));
21CREATE TABLE IF NOT EXISTS counters(prefix TEXT PRIMARY KEY, n INTEGER NOT NULL);
22CREATE TABLE IF NOT EXISTS activity(
23  id INTEGER PRIMARY KEY AUTOINCREMENT, item_id TEXT NOT NULL, author TEXT NOT NULL,
24  type TEXT NOT NULL, body TEXT NOT NULL, at TEXT NOT NULL DEFAULT (${NOW}));
25CREATE INDEX IF NOT EXISTS activity_item ON activity(item_id);
26CREATE TABLE IF NOT EXISTS links(blocker TEXT NOT NULL, blocked TEXT NOT NULL, PRIMARY KEY (blocker, blocked));
27CREATE TABLE IF NOT EXISTS checks(item_id TEXT NOT NULL, n INTEGER NOT NULL, text TEXT NOT NULL,
28  done INTEGER NOT NULL DEFAULT 0, PRIMARY KEY (item_id, n));
29CREATE TABLE IF NOT EXISTS reads(reader TEXT NOT NULL, item_id TEXT NOT NULL, seen INTEGER NOT NULL,
30  PRIMARY KEY (reader, item_id));`,
31  // v2: Jira-style fields, claim leases, labels, and links other than blocked-by. ALTER TABLE is not
32  // idempotent, so a session that loses the race to migrate fails here and finds the version current.
33  `ALTER TABLE items ADD COLUMN priority TEXT NOT NULL DEFAULT 'p2';
34ALTER TABLE items ADD COLUMN type TEXT NOT NULL DEFAULT 'feature';
35ALTER TABLE items ADD COLUMN lease_at TEXT;
36CREATE TABLE IF NOT EXISTS labels(item_id TEXT NOT NULL, label TEXT NOT NULL, PRIMARY KEY (item_id, label));
37CREATE TABLE IF NOT EXISTS relations(a TEXT NOT NULL, b TEXT NOT NULL, type TEXT NOT NULL, PRIMARY KEY (a, b, type));`,
38  // v3: a milestone or epic handed to an agent is now reviewed once its tasks are done. Those already
39  // finished before that were never asked for review, so they keep reading done.
40  `UPDATE items SET status='done' WHERE kind!='task' AND id IN (
41  WITH RECURSIVE under(root, id) AS (
42    SELECT id, id FROM items WHERE kind!='task'
43    UNION ALL SELECT under.root, items.id FROM items JOIN under ON items.parent=under.id)
44  SELECT root FROM under JOIN items ON items.id=under.id WHERE items.kind='task'
45  GROUP BY root HAVING SUM(items.status!='done')=0);`,
46  // v4: undo. Each logged change keeps the script that takes it back (undo) and the one that makes it
47  // again (redo); the entries one transaction writes share an op. A reverted entry names the undo that
48  // reverted it (undone); an undo names the entry it reverted (reverts).
49  `ALTER TABLE activity ADD COLUMN undo TEXT;
50ALTER TABLE activity ADD COLUMN redo TEXT;
51ALTER TABLE activity ADD COLUMN op INTEGER;
52ALTER TABLE activity ADD COLUMN undone INTEGER;
53ALTER TABLE activity ADD COLUMN reverts INTEGER;`,
54  // v5: release notes. A task's line for the CHANGELOG, and the section it goes under.
55  `ALTER TABLE items ADD COLUMN note TEXT;
56ALTER TABLE items ADD COLUMN section TEXT;`,
57  // v6: a task closed as won't do: done, but dropped rather than finished.
58  `ALTER TABLE items ADD COLUMN resolution TEXT;`,
59  // v7: milestones are targets, not containers: what sat under a milestone targets it instead.
60  `ALTER TABLE items ADD COLUMN milestone TEXT;
61${RETARGET}`,
62  // v8: releases: each version shipped, and the tasks it carried with their notes as they went out.
63  `CREATE TABLE IF NOT EXISTS releases(version TEXT PRIMARY KEY, tag TEXT, at TEXT NOT NULL, pr INTEGER, notes TEXT NOT NULL DEFAULT '');
64CREATE TABLE IF NOT EXISTS shipped(version TEXT NOT NULL, item_id TEXT NOT NULL, note TEXT NOT NULL, section TEXT,
65  PRIMARY KEY (version, item_id));`,
66  // v9: the inbox: things filed to sort later, kept apart from planned work.
67  `CREATE TABLE IF NOT EXISTS inbox(id TEXT PRIMARY KEY, title TEXT NOT NULL, body TEXT, author TEXT NOT NULL,
68  at TEXT NOT NULL DEFAULT (${NOW}), state TEXT NOT NULL DEFAULT 'open', became TEXT, reason TEXT);`,
69  // v10: when a milestone or epic is meant to start, for the roadmap's time axis.
70  `ALTER TABLE items ADD COLUMN start TEXT;`,
71]
72
73/** The schema version this build of the mod reads and writes. */
74export const VERSION = MIGRATIONS.length
75
76/**
77 * The op a write's log entries share: the id the first of them gets. A line of its own right after
78 * BEGIN, so `atomic` can run several scripts as one op.
79 */
80export const OP = 'CREATE TEMP TABLE IF NOT EXISTS op(n INTEGER); DELETE FROM op; INSERT INTO op SELECT COALESCE(MAX(id), 0) + 1 FROM activity;'
81
82/** How every write that logs begins: its transaction, and its op. */
83export const BEGIN = `BEGIN IMMEDIATE;\n${OP}`
84
85/** Answers the database's schema version. */
86export const READ_VERSION = 'PRAGMA user_version;'
87
88/**
89 * The script taking a database from version `from` to VERSION in one transaction (WAL set first: it
90 * cannot change inside one). Two sessions racing here serialize on BEGIN IMMEDIATE; see `isCurrent`.
91 */
92export const migrate = (from: number) => `PRAGMA journal_mode=WAL;
93BEGIN IMMEDIATE;
94${MIGRATIONS.slice(from).join('\n')}
95PRAGMA user_version=${VERSION};
96COMMIT;`
97
98/** Why a database cannot be used by this build, or undefined when it can (after migrating if older). */
99export function versionProblem(version: number): string | undefined {
100  if (!Number.isInteger(version) || version < 0) return `${DB} reports schema version "${version}", which is not a version`
101  if (version > VERSION)
102    return `${DB} was written by a newer roadmap mod (schema v${version}; this one reads up to v${VERSION}). Update the mod; nothing was changed.`
103  return undefined
104}
105
106/** A SQL literal. Newlines are spliced in with char(10) so no line of the script can read as a dot-command. */
107export function q(value: string | number | null | undefined): string {
108  if (value === null || value === undefined) return 'NULL'
109  if (typeof value === 'number') return String(Math.trunc(value))
110  return value
111    .replace(/\0/g, '')
112    .split(/\r?\n/)
113    .map(part => `'${part.replace(/'/g, "''")}'`)
114    .join('||char(10)||')
115}
116
117/**
118 * The sqlite3 command line a script runs under; the script goes on stdin. Safe mode refuses .shell,
119 * .system and the like: undo runs SQL read back out of the database, which a cloned repository or an
120 * imported export could have written, and a dot-command line there would otherwise run as a command.
121 */
122export const ARGV = ['sqlite3', '-safe', '-batch', '-bail', '-noheader', '-list', '-cmd', '.timeout 5000', DB]
123
124/** The command line for the database at `path` (a batch's trial copy, say) in place of DB. */
125export const argvFor = (path: string) => [...ARGV.slice(0, -1), path]
126
127/** What every write moves on: the newest timeline entry. Unchanged, nobody has written since it was read. */
128export const STAMP = 'SELECT COALESCE(MAX(id), 0) FROM activity;'
129
130/**
131 * A statement that fails, and so (under -bail) rolls back the transaction it is in, unless the database
132 * still reads `stamp`: a batch's writes replay only on the database they were tried against.
133 */
134export const expectStamp = (stamp: string) =>
135  guard(`(SELECT COALESCE(MAX(id), 0) FROM activity) = ${Number(stamp) || 0}`, 'the roadmap changed meanwhile; nothing was written. Try again.')
136
137/**
138 * A statement that fails the script it is in (under -bail, rolling back its transaction) unless `cond`
139 * holds, with `message` in sqlite3's error: an invalid JSON path is the one error SQL lets a script word.
140 */
141export const guard = (cond: string, message: string) =>
142  `SELECT CASE WHEN NOT (${cond}) THEN json_extract('{}', ${q(`!${message}`)}) END;`
143
144/** The message of the guard that failed, read out of sqlite3's error; undefined when no guard failed. */
145export function guardReason(error: string): string | undefined {
146  const found = /'!([\s\S]*)'\s*$/.exec(error.trim())
147  return found ? found[1]!.replace(/''/g, "'") : undefined
148}
149
150/** A script's answer: what its last statement printed. */
151export const answer = (stdout: string) => stdout.trim().split('\n').at(-1) ?? ''
152
153/** How a logged change is taken back and made again; `reverts` on an undo, the entry it reverted. */
154type Back = { undo?: string; redo?: string; reverts?: number }
155
156const activity = (id: string, author: string, type: string, body: string, back: Back = {}) =>
157  `INSERT INTO activity(item_id, author, type, body, op, undo, redo, reverts) VALUES (${q(id)}, ${q(author)}, ${q(type)}, ${q(body)}, (SELECT n FROM op), ${q(back.undo)}, ${q(back.redo)}, ${q(back.reverts)});`
158
159const changedSince = (id: string, what: string) => `${id}'s ${what} has changed since; change it directly`
160
161/** Sets an item's `field` from `from` to `to`, failing when it no longer reads `from`. */
162const setField = (id: string, field: string, from: string | null, to: string | null) =>
163  `${guard(`EXISTS (SELECT 1 FROM items WHERE id=${q(id)} AND ${field} IS ${q(from)})`, changedSince(id, field))}
164UPDATE items SET ${field}=${q(to)}, updated_at=${NOW} WHERE id=${q(id)};`
165
166/** A field change both ways. */
167const fieldBack = (id: string, field: string, from: string | null, to: string | null): Back =>
168  ({ undo: setField(id, field, to, from), redo: setField(id, field, from, to) })
169
170// Lists compared as one text, joined by a character no label or criterion holds.
171const SEP = '\u001e'
172const labelsAre = (id: string, list: string[]) =>
173  `COALESCE((SELECT group_concat(label, char(30)) FROM (SELECT label FROM labels WHERE item_id=${q(id)} ORDER BY label)), '') = ${q([...list].sort().join(SEP))}`
174const writeLabels = (id: string, list: string[]) => `DELETE FROM labels WHERE item_id=${q(id)};
175${list.map(one => `INSERT INTO labels(item_id, label) VALUES (${q(id)}, ${q(one)});`).join('\n')}
176UPDATE items SET updated_at=${NOW} WHERE id=${q(id)};`
177const checksAre = (id: string, list: Check[]) =>
178  `COALESCE((SELECT group_concat(text, char(30)) FROM (SELECT text FROM checks WHERE item_id=${q(id)} ORDER BY n)), '') = ${q(list.map(c => c.text).join(SEP))}`
179const writeChecks = (id: string, list: Check[]) => `DELETE FROM checks WHERE item_id=${q(id)};
180${list.map(c => `INSERT INTO checks(item_id, n, text, done) VALUES (${q(id)}, ${c.n}, ${q(c.text)}, ${c.done ? 1 : 0});`).join('\n')}
181UPDATE items SET updated_at=${NOW} WHERE id=${q(id)};`
182const link = (blocker: string, blocked: string, isOn: boolean) => isOn
183  ? `INSERT OR IGNORE INTO links(blocker, blocked) VALUES (${q(blocker)}, ${q(blocked)});`
184  : `DELETE FROM links WHERE blocker=${q(blocker)} AND blocked=${q(blocked)};`
185const relation = (a: string, b: string, type: string, isOn: boolean) => isOn
186  ? `INSERT OR IGNORE INTO relations(a, b, type) VALUES (${q(a)}, ${q(b)}, ${q(type)});`
187  : `DELETE FROM relations WHERE a=${q(a)} AND b=${q(b)} AND type=${q(type)};`
188const tick = (id: string, n: number, from: boolean, to: boolean) =>
189  `${guard(`EXISTS (SELECT 1 FROM checks WHERE item_id=${q(id)} AND n=${n} AND done=${from ? 1 : 0})`, changedSince(id, `criterion ${n}`))}
190UPDATE checks SET done=${to ? 1 : 0} WHERE item_id=${q(id)} AND n=${n};`
191
192/**
193 * How much of each item's timeline the snapshot carries: enough for a card and a brief. Its latest
194 * handoff note always comes along; the whole of it is read for one item with `history`.
195 */
196export const RECENT = 20
197
198/** Loads the roadmap, with what `reader` has seen of each item. */
199export const load = (reader: string) => `SELECT json_object(
200      'items', (SELECT json_group_array(json_object('id', id, 'kind', kind, 'title', title, 'status', status,
201        'parent', parent, 'milestone', milestone, 'description', description, 'assignee', assignee, 'start', start, 'due', due,
202        'priority', priority, 'type', type, 'note', note, 'section', section, 'resolution', resolution, 'lease_at', lease_at, 'created_at', created_at, 'updated_at', updated_at,
203        'labels', json((SELECT json_group_array(label) FROM (SELECT label FROM labels WHERE item_id=items.id ORDER BY label))),
204        'relations', json((SELECT json_group_array(json_object('type', type, 'id', b)) FROM relations WHERE a=items.id)),
205        'blocked_by', json((SELECT json_group_array(blocker) FROM links WHERE blocked=items.id)),
206        'checklist', json((SELECT json_group_array(json_object('n', n, 'text', text, 'done', done))
207          FROM checks WHERE item_id=items.id)))) FROM items),
208      'activity', (SELECT json_group_array(json_object('id', id, 'item_id', item_id, 'author', author,
209        'type', type, 'body', body, 'at', at, 'op', op, 'undone', undone,
210        'undoable', undo IS NOT NULL OR type IN ('comment', 'handoff', 'create'))) FROM (SELECT * FROM (
211          SELECT *, ROW_NUMBER() OVER (PARTITION BY item_id ORDER BY id DESC) AS nth FROM activity)
212        WHERE nth <= ${RECENT} OR id IN (SELECT MAX(id) FROM activity WHERE type='handoff' GROUP BY item_id)
213        ORDER BY id DESC)),
214      'seen', (SELECT json_group_object(item_id, seen) FROM reads WHERE reader=${q(reader)}),
215      'releases', (SELECT json_group_array(json_object('version', version, 'tag', tag, 'at', at, 'pr', pr, 'notes', notes,
216        'tasks', json((SELECT json_group_array(json_object('id', item_id, 'note', note, 'section', section)) FROM shipped
217          WHERE shipped.version=releases.version)))) FROM releases),
218      'inbox', (SELECT json_group_array(json_object('id', id, 'title', title, 'body', body, 'author', author, 'at', at,
219        'state', state, 'became', became, 'reason', reason)) FROM (SELECT * FROM inbox ORDER BY CAST(SUBSTR(id, 2) AS INTEGER))));`
220
221/** An item's whole timeline, oldest first, as a JSON list. */
222export const history = (id: string) =>
223  `SELECT json_group_array(json_object('id', id, 'item_id', item_id, 'author', author, 'type', type, 'body', body, 'at', at))
224  FROM (SELECT * FROM activity WHERE item_id=${q(id)} ORDER BY id);`
225
226/** Everything written on each item (comments and handoff notes), one text per item id, as a JSON object. */
227export const said = `SELECT json_group_object(item_id, body) FROM (SELECT item_id, group_concat(body, char(10)) AS body
228  FROM (SELECT * FROM activity WHERE type IN ('comment', 'handoff') ORDER BY id) GROUP BY item_id);`
229
230export function parseLoad(out: string): Snapshot {
231  const data = JSON.parse(out) as Snapshot
232  // sqlite3 hands back `done` as 0/1, and json_group_array keeps no order of its own.
233  const items = data.items.map(item => ({
234    ...item,
235    labels: item.labels ?? [],
236    relations: item.relations ?? [],
237    checklist: (item.checklist ?? []).map(c => ({ ...c, done: Boolean(c.done) })).sort((a, b) => a.n - b.n),
238  }))
239  const activity = data.activity.map(one => ({ ...one, undoable: Boolean(one.undoable) }))
240  return { items, activity, seen: data.seen ?? {}, releases: data.releases ?? [], inbox: data.inbox ?? [] }
241}
242
243export type NewItem = {
244  kind: Kind
245  title: string
246  parent: string | null
247  milestone?: string | null
248  start?: string
249  description?: string
250  due?: string
251  status?: Status
252  assignee?: string
253  priority?: Priority
254  type?: IssueType
255}
256
257/**
258 * Inserts an item under a fresh id (ids are never reused); the script answers that id, read inside the
259 * transaction: after COMMIT another writer may already have moved the counter on.
260 */
261export function insert(actor: string, item: NewItem): string {
262  const prefix = PREFIX[item.kind]
263  const id = `${q(prefix)}||(SELECT n FROM counters WHERE prefix=${q(prefix)})`
264  const created = `created ${item.kind} “${item.title}”${item.assignee ? `, assigned to ${item.assignee}` : ''}`
265  return `${BEGIN}
266INSERT INTO counters(prefix, n) VALUES (${q(prefix)},
267  COALESCE((SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) FROM items WHERE SUBSTR(id, 1, 1)=${q(prefix)}), 0) + 1)
268  ON CONFLICT(prefix) DO UPDATE SET n = n + 1;
269INSERT INTO items(id, kind, title, status, parent, milestone, description, assignee, start, due, priority, type) VALUES (${id}, ${q(item.kind)},
270  ${q(item.title)}, ${q(item.status ?? 'todo')}, ${q(item.parent)}, ${q(item.milestone ?? null)}, ${q(item.description || null)},
271  ${q(item.assignee || null)}, ${q(item.start || null)}, ${q(item.due || null)}, ${q(item.priority ?? 'p2')}, ${q(item.type ?? 'feature')});
272INSERT INTO activity(item_id, author, type, body, op) VALUES (${id}, ${q(actor)}, 'create', ${q(created)}, (SELECT n FROM op));
273SELECT ${id};
274COMMIT;`
275}
276
277export type Changes = Partial<Pick<Item, 'title' | 'status' | 'parent' | 'milestone' | 'start' | 'description' | 'assignee' | 'due' | 'priority' | 'type' | 'note' | 'section' | 'resolution'>>
278
279/** The script writing the changes, logging one activity entry per field changed, and those entries; none when nothing changes. */
280export function change(actor: string, item: Item, changes: Changes): { script: string; notes: string[] } {
281  const sets: string[] = []
282  const logs: string[] = []
283  const notes: string[] = []
284  for (const [field, value] of Object.entries(changes) as [keyof Changes, string | null][]) {
285    if (value === undefined || value === item[field]) continue
286    sets.push(`${field}=${q(value)}`)
287    const log = (type: string, body: string) =>
288      (logs.push(activity(item.id, actor, type, body, fieldBack(item.id, field, item[field] as string | null, value))), notes.push(body))
289    if (field === 'status') log('status', `status ${item.status} → ${value}`)
290    else if (field === 'assignee')
291      log('assign', value === null ? `unassigned ${item.assignee}` : value === actor ? 'claimed' : `assigned to ${value}`)
292    else if (field === 'parent') log('edit', value === null ? 'out of its epic' : `moved under ${value}`)
293    else if (field === 'milestone') log('edit', value === null ? 'no longer targets a milestone' : `targets ${value}`)
294    else if (field === 'description') log('edit', value ? 'description updated' : 'description cleared')
295    else if (field === 'note') log('edit', value === null ? 'release note cleared' : value === NO_NOTE ? 'no release note needed' : `release note: ${value}`)
296    else if (field === 'resolution') log('edit', value === null ? "no longer won't do" : "closed as won't do")
297    else log('edit', value === null ? `${field} cleared` : `${field} → ${value}`)
298  }
299  if (sets.length === 0) return { script: '', notes }
300  return {
301    script: `${BEGIN}
302UPDATE items SET ${sets.join(', ')}, updated_at=${NOW} WHERE id=${q(item.id)};
303${logs.join('\n')}
304COMMIT;`,
305    notes,
306  }
307}
308
309// When a lease taken or renewed now runs out, as the SQL compares it.
310const LEASE_CUTOFF = `strftime('%Y-%m-%dT%H:%M:%SZ','now','-30 minutes')`
311
312/**
313 * Claims a task for `actor` in one transaction: it takes the task only when no one else holds it, the
314 * holder's lease has run out (see LEASE_MS), or `force`; starts it, and starts a lease. Answers who
315 * holds the task afterwards. `from` is who held it as last read, for the log of a takeover.
316 */
317export function claim(actor: string, id: string, force: boolean, from?: string | null, was?: Status): string {
318  const note = from && from !== actor ? (force ? `took over from ${from}` : `took over stale claim from ${from}`) : 'claimed'
319  // Taken back: the holder and status it had. Known only when the caller says what the task was.
320  const now = was === 'todo' ? 'in_progress' : was
321  const back: Back = was === undefined ? {} : {
322    undo: `${guard(`EXISTS (SELECT 1 FROM items WHERE id=${q(id)} AND assignee IS ${q(actor)})`, changedSince(id, 'assignee'))}
323UPDATE items SET assignee=${q(from ?? null)}, status=${q(was)}, updated_at=${NOW} WHERE id=${q(id)};`,
324    redo: `${guard(`EXISTS (SELECT 1 FROM items WHERE id=${q(id)} AND assignee IS ${q(from ?? null)})`, changedSince(id, 'assignee'))}
325UPDATE items SET assignee=${q(actor)}, status=${q(now)}, lease_at=${NOW}, updated_at=${NOW} WHERE id=${q(id)};`,
326  }
327  return `${BEGIN}
328UPDATE items SET assignee=${q(actor)}, status=CASE status WHEN 'todo' THEN 'in_progress' ELSE status END,
329  lease_at=${NOW}, updated_at=${NOW}
330  WHERE id=${q(id)} AND (assignee IS NULL OR assignee=${q(actor)} OR ${force ? 1 : 0}
331    OR (status='in_progress' AND COALESCE(lease_at, updated_at) < ${LEASE_CUTOFF}))
332  -- Already theirs (handed over from the board): claiming still starts it.
333  AND (assignee IS NOT ${q(actor)} OR status='todo');
334INSERT INTO activity(item_id, author, type, body, op, undo, redo)
335  SELECT ${q(id)}, ${q(actor)}, 'assign', ${q(note)}, (SELECT n FROM op), ${q(back.undo)}, ${q(back.redo)} WHERE changes() > 0;
336COMMIT;
337SELECT assignee FROM items WHERE id=${q(id)};`
338}
339
340/** Renews the leases on everything `actor` is working on: a heartbeat, so it leaves no trace in the timeline. */
341export const renew = (actor: string) =>
342  `UPDATE items SET lease_at=${NOW} WHERE assignee=${q(actor)} AND status='in_progress' AND kind='task';`
343
344export const comment = (actor: string, id: string, body: string, type: 'comment' | 'handoff' = 'comment') =>
345  `${BEGIN}\n${activity(id, actor, type, body)}\nCOMMIT;`
346
347/**
348 * Deletes items and everything on them. Their removals and undos stay in the timeline, so a removal can
349 * be taken back, and the taking back undone, under the removed item's id.
350 */
351export function removeRows(ids: string[]): string {
352  const list = ids.map(q).join(', ')
353  return `DELETE FROM items WHERE id IN (${list});\nDELETE FROM activity WHERE item_id IN (${list}) AND type NOT IN ('remove', 'undo');\nDELETE FROM reads WHERE item_id IN (${list});\nDELETE FROM links WHERE blocker IN (${list}) OR blocked IN (${list});\nDELETE FROM checks WHERE item_id IN (${list});\nDELETE FROM labels WHERE item_id IN (${list});\nDELETE FROM relations WHERE a IN (${list}) OR b IN (${list});`
354}
355
356/**
357 * Removes items (a subtree, its root first). With `log`, the removal is logged on the root with what
358 * restores it: `rows`, everything on those items as `dump` read it.
359 */
360export function remove(ids: string[], log?: { actor: string; body: string; rows: Rows }): string {
361  const logged = log ? activity(ids[0]!, log.actor, 'remove', log.body, { undo: restore(log.rows), redo: removeRows(ids) }) : ''
362  return `${BEGIN}\n${removeRows(ids)}\n${logged}\nCOMMIT;`
363}
364
365/** Every table's columns, as `dump` reads and `restore` writes them. */
366export const TABLES = {
367  items: ['id', 'kind', 'title', 'status', 'parent', 'milestone', 'description', 'assignee', 'start', 'due', 'priority', 'type', 'note', 'section', 'resolution', 'lease_at', 'created_at', 'updated_at'],
368  activity: ['id', 'item_id', 'author', 'type', 'body', 'at', 'undo', 'redo', 'op', 'undone', 'reverts'],
369  links: ['blocker', 'blocked'],
370  checks: ['item_id', 'n', 'text', 'done'],
371  labels: ['item_id', 'label'],
372  relations: ['a', 'b', 'type'],
373  reads: ['reader', 'item_id', 'seen'],
374  releases: ['version', 'tag', 'at', 'pr', 'notes'],
375  shipped: ['version', 'item_id', 'note', 'section'],
376  inbox: ['id', 'title', 'body', 'author', 'at', 'state', 'became', 'reason'],
377  counters: ['prefix', 'n'],
378} as const
379
380export type Table = keyof typeof TABLES
381/** Rows by table, as `dump` answers them. */
382export type Rows = Partial<Record<Table, Record<string, string | number | null>[]>>
383
384/** Which rows of each table belong to the items in `list` (a SQL list of ids). */
385const OWNED: Record<Exclude<Table, 'counters'>, (list: string) => string> = {
386  items: list => `id IN (${list})`,
387  activity: list => `item_id IN (${list})`,
388  links: list => `blocker IN (${list}) OR blocked IN (${list})`,
389  checks: list => `item_id IN (${list})`,
390  labels: list => `item_id IN (${list})`,
391  relations: list => `a IN (${list}) OR b IN (${list})`,
392  reads: list => `item_id IN (${list})`,
393  // A release belongs to no item; what it shipped of an item goes with that item.
394  releases: () => 'FALSE',
395  shipped: list => `item_id IN (${list})`,
396  inbox: () => 'FALSE',
397}
398
399/** Reads every row on the items `ids` (all of the roadmap, counters too, when absent) as JSON: Rows. */
400export function dump(ids?: string[]): string {
401  const list = ids?.map(q).join(', ')
402  const tables = (Object.keys(TABLES) as Table[]).filter(table => !ids || table !== 'counters')
403  const parts = tables.map(table => {
404    const cols = TABLES[table].map(col => `'${col}', ${col}`).join(', ')
405    const where = ids && table !== 'counters' ? ` WHERE ${OWNED[table](list!)}` : ''
406    return `'${table}', (SELECT json_group_array(json_object(${cols})) FROM ${table}${where})`
407  })
408  return `SELECT json_object(${parts.join(',\n  ')});`
409}
410
411/**
412 * Statements writing `rows` back, leaving rows that are already there alone (counters move up to the
413 * higher of the two). No BEGIN or COMMIT: they go inside a script of the caller's.
414 */
415export function restore(rows: Rows): string {
416  const out: string[] = []
417  for (const table of Object.keys(TABLES) as Table[]) {
418    const cols = TABLES[table] as readonly string[]
419    for (const row of rows[table] ?? []) {
420      // Only the columns the row has: one from an older schema leaves the newer ones to their defaults.
421      const has = cols.filter(col => col in row)
422      const values = has.map(col => q(row[col] ?? null)).join(', ')
423      out.push(table === 'counters'
424        ? `INSERT INTO counters(prefix, n) VALUES (${values}) ON CONFLICT(prefix) DO UPDATE SET n=MAX(n, excluded.n);`
425        : `INSERT OR IGNORE INTO ${table}(${has.join(', ')}) VALUES (${values});`)
426    }
427  }
428  return out.join('\n')
429}
430
431/** What a roadmap export holds: the schema version it was written at, when, and every row. */
432export type Export = { roadmap: 'export'; schema: number; exported_at: string; tables: Rows }
433
434/** An export of `rows` (a whole `dump`), as the text written to a file. */
435export const exportOf = (rows: Rows, at: string): string =>
436  `${JSON.stringify({ roadmap: 'export', schema: VERSION, exported_at: at, tables: rows } satisfies Export)}\n`
437
438/** The rows of an export's text, or throws saying why it can't be imported here. */
439export function importOf(text: string): Rows {
440  let data: Partial<Export>
441  try {
442    data = JSON.parse(text) as Partial<Export>
443  } catch {
444    throw new Error('not a roadmap export: the file is not JSON')
445  }
446  if (data?.roadmap !== 'export' || typeof data.tables !== 'object' || data.tables === null) throw new Error('not a roadmap export')
447  if (!Number.isInteger(data.schema) || data.schema! > VERSION)
448    throw new Error(`the export is from a newer roadmap mod (schema v${data.schema}; this one reads up to v${VERSION}). Update the mod first`)
449  for (const table of Object.keys(data.tables)) if (!(table in TABLES)) throw new Error(`not a roadmap export: unknown table ${table}`)
450  return data.tables
451}
452
453/** How many items and log entries a roadmap holds; an import goes only into one holding neither. */
454export const COUNT = `SELECT (SELECT count(*) FROM items) || ' ' || (SELECT count(*) FROM activity);`
455
456/** Writes an export's rows into an empty roadmap, in one transaction. */
457export const importRows = (rows: Rows) => `BEGIN IMMEDIATE;\n${restore(rows)}\n${RETARGET}\nCOMMIT;`
458
459/** A logged entry as `entries` reads it: what undo needs. */
460export type Entry = { id: number; item_id: string; author: string; type: string; body: string; at: string; op: number | null; undo: string | null; redo: string | null; undone: number | null; reverts: number | null }
461
462/** Reads the entries `ids` as a JSON list of Entry. */
463export const entries = (ids: number[]) =>
464  `SELECT json_group_array(json_object('id', id, 'item_id', item_id, 'author', author, 'type', type, 'body', body, 'at', at,
465    'op', op, 'undo', undo, 'redo', redo, 'undone', undone, 'reverts', reverts))
466  FROM activity WHERE id IN (${ids.map(id => Math.trunc(id)).join(', ') || 'NULL'});`
467
468/** A comment taken back, and written again as it was, under its own id. */
469export const unsay = (one: Entry) => `DELETE FROM activity WHERE id=${Math.trunc(one.id)};`
470export const resay = (one: Entry) =>
471  `INSERT OR IGNORE INTO activity(id, item_id, author, type, body, at, op) VALUES (${Math.trunc(one.id)}, ${q(one.item_id)}, ${q(one.author)}, ${q(one.type)}, ${q(one.body)}, ${q(one.at)}, ${q(one.op)});`
472
473/**
474 * Reverts entries as `actor`, newest first, in one transaction that fails whole when anything changed
475 * since (`stamp`) or any of them no longer can be. Each is logged as an undo, which can itself be undone:
476 * its undo is the entry's redo, and the other way round.
477 */
478export function revert(actor: string, list: { entry: Entry; undo: string; redo: string }[], stamp: string): string {
479  const parts = [...list].sort((a, b) => b.entry.id - a.entry.id).map(({ entry, undo, redo }) => {
480    const body = entry.type === 'undo'
481      ? entry.body.replace(/^(undid|redid)/, word => (word === 'undid' ? 'redid' : 'undid'))
482      : `undid “${entry.body}”`
483    return [
484      undo,
485      activity(entry.item_id, actor, 'undo', body, { undo: redo, redo: undo, reverts: entry.id }),
486      `UPDATE activity SET undone=(SELECT MAX(id) FROM activity) WHERE id=${Math.trunc(entry.id)};`,
487      // Redone: what the undo took back stands again, and can be undone again.
488      entry.type === 'undo' && entry.reverts ? `UPDATE activity SET undone=NULL WHERE id=${Math.trunc(entry.reverts)};` : '',
489    ].filter(Boolean).join('\n')
490  })
491  return `${BEGIN}\n${expectStamp(stamp)}\n${parts.join('\n')}\nCOMMIT;`
492}
493
494/**
495 * Scripts built here, run as one transaction: each one's own BEGIN and COMMIT lines dropped (text never
496 * makes such a line: `q` splices its newlines in as char(10)). Empty ones are skipped.
497 */
498export function atomic(scripts: string[]): string {
499  const bodies = scripts.filter(Boolean).map(one => one.split('\n').filter(line => line !== 'BEGIN IMMEDIATE;' && line !== OP && line !== 'COMMIT;').join('\n'))
500  return bodies.length ? `${BEGIN}\n${bodies.join('\n')}\nCOMMIT;` : ''
501}
502
503/** Files `title` (and `body`) to the inbox under a fresh I-id; the script answers that id. */
504export function fileInbox(author: string, title: string, body?: string | null): string {
505  const id = `'I'||(SELECT n FROM counters WHERE prefix='I')`
506  return `BEGIN IMMEDIATE;
507INSERT INTO counters(prefix, n) VALUES ('I', COALESCE((SELECT MAX(CAST(SUBSTR(id, 2) AS INTEGER)) FROM inbox), 0) + 1)
508  ON CONFLICT(prefix) DO UPDATE SET n = n + 1;
509INSERT INTO inbox(id, title, body, author) VALUES (${id}, ${q(title)}, ${q(body || null)}, ${q(author)});
510SELECT ${id};
511COMMIT;`
512}
513
514/** Sorts an inbox item: triaged into the item `became`, or dropped with `reason`; only while it is open. */
515export const resolveInbox = (id: string, state: 'triaged' | 'dropped', became: string | null, reason: string | null) => `BEGIN IMMEDIATE;
516${guard(`EXISTS (SELECT 1 FROM inbox WHERE id=${q(id)} AND state='open')`, `${id} was sorted meanwhile; nothing was changed`)}
517UPDATE inbox SET state=${q(state)}, became=${q(became)}, reason=${q(reason)} WHERE id=${q(id)};
518COMMIT;`
519
520/** Records a release and what it shipped, replacing any record of that version. */
521export function recordRelease(r: Release): string {
522  const rows = r.tasks.map(one => `INSERT INTO shipped(version, item_id, note, section) VALUES (${q(r.version)}, ${q(one.id)}, ${q(one.note)}, ${q(one.section)});`)
523  return `BEGIN IMMEDIATE;
524INSERT OR REPLACE INTO releases(version, tag, at, pr, notes) VALUES (${q(r.version)}, ${q(r.tag)}, ${q(r.at)}, ${r.pr === null ? 'NULL' : Math.trunc(r.pr)}, ${q(r.notes)});
525DELETE FROM shipped WHERE version=${q(r.version)};
526${rows.join('\n')}
527COMMIT;`
528}
529
530/** Marks everything on an item as seen by `reader`, up to its newest activity. */
531export const markSeen = (reader: string, id: string) =>
532  `INSERT INTO reads(reader, item_id, seen) VALUES (${q(reader)}, ${q(id)},
533  (SELECT COALESCE(MAX(id), 0) FROM activity WHERE item_id=${q(id)}))
534  ON CONFLICT(reader, item_id) DO UPDATE SET seen=excluded.seen;`
535
536/** Marks everything on every item as seen by `reader`, up to each one's newest activity. */
537export const markAllSeen = (reader: string) =>
538  `INSERT INTO reads(reader, item_id, seen) SELECT ${q(reader)}, item_id, MAX(id) FROM activity
539  WHERE item_id IN (SELECT id FROM items) GROUP BY item_id
540  ON CONFLICT(reader, item_id) DO UPDATE SET seen=excluded.seen;`
541
542/** The script setting what `item` waits on to exactly `next`, logging each link made or dropped; none when unchanged. */
543export function setBlockers(actor: string, item: Item, next: string[]): { script: string; notes: string[] } {
544  const added = next.filter(id => !item.blocked_by.includes(id))
545  const dropped = item.blocked_by.filter(id => !next.includes(id))
546  const notes = [...added.map(id => `blocked by ${id}`), ...dropped.map(id => `no longer blocked by ${id}`)]
547  if (notes.length === 0) return { script: '', notes }
548  const logs = [
549    ...added.map(id => activity(item.id, actor, 'edit', `blocked by ${id}`, { undo: link(id, item.id, false), redo: link(id, item.id, true) })),
550    ...dropped.map(id => activity(item.id, actor, 'edit', `no longer blocked by ${id}`, { undo: link(id, item.id, true), redo: link(id, item.id, false) })),
551  ]
552  return {
553    script: `${BEGIN}
554${dropped.map(id => link(id, item.id, false)).join('\n')}
555${added.map(id => link(id, item.id, true)).join('\n')}
556${logs.join('\n')}
557UPDATE items SET updated_at=${NOW} WHERE id=${q(item.id)};
558COMMIT;`,
559    notes,
560  }
561}
562
563/** The note of a task whose work needs no line in the CHANGELOG. */
564export const NO_NOTE = '-'
565
566/** A label as stored: lowercase, words joined by hyphens, no leading `#`. */
567export const label = (text: string) => text.trim().replace(/^#+/, '').toLowerCase().replace(/\s+/g, '-')
568
569/** The script setting an item's labels to exactly `next`, logging the new set; none when unchanged. */
570export function setLabels(actor: string, item: Item, next: string[]): { script: string; notes: string[] } {
571  const wanted = [...new Set(next.map(label).filter(Boolean))].sort()
572  if (wanted.join('\n') === [...item.labels].sort().join('\n')) return { script: '', notes: [] }
573  const notes = [wanted.length ? `labels: ${wanted.join(', ')}` : 'labels cleared']
574  const back = (from: string[], to: string[]) => `${guard(labelsAre(item.id, from), changedSince(item.id, 'labels'))}\n${writeLabels(item.id, to)}`
575  return {
576    script: `${BEGIN}
577${writeLabels(item.id, wanted)}
578${activity(item.id, actor, 'edit', notes[0]!, { undo: back(wanted, item.labels), redo: back(item.labels, wanted) })}
579COMMIT;`,
580    notes,
581  }
582}
583
584const RELATION_NOTE = { relates: ['relates to', 'no longer relates to'], duplicates: ['duplicate of', 'no longer a duplicate of'] }
585
586/**
587 * The script setting the links of one `type` that `item` makes to exactly `next`, logging each made or
588 * dropped; none when unchanged. Marking a task a duplicate also closes it: the work lives on elsewhere.
589 */
590export function setRelations(actor: string, item: Item, type: Relation['type'], next: string[]): { script: string; notes: string[] } {
591  const now = item.relations.filter(one => one.type === type).map(one => one.id)
592  const added = next.filter(id => !now.includes(id))
593  const dropped = now.filter(id => !next.includes(id))
594  const [made, gone] = RELATION_NOTE[type]
595  const notes = [...added.map(id => `${made} ${id}`), ...dropped.map(id => `${gone} ${id}`)]
596  if (notes.length === 0) return { script: '', notes }
597  const closes = type === 'duplicates' && added.length > 0 && item.kind === 'task' && item.status !== 'done'
598  const logs = [
599    ...added.map(id => activity(item.id, actor, 'edit', `${made} ${id}`, { undo: relation(item.id, id, type, false), redo: relation(item.id, id, type, true) })),
600    ...dropped.map(id => activity(item.id, actor, 'edit', `${gone} ${id}`, { undo: relation(item.id, id, type, true), redo: relation(item.id, id, type, false) })),
601  ]
602  if (closes) {
603    notes.push(`status ${item.status} → done (closed as a duplicate)`)
604    logs.push(activity(item.id, actor, 'status', notes.at(-1)!, fieldBack(item.id, 'status', item.status, 'done')))
605  }
606  return {
607    script: `${BEGIN}
608${dropped.map(id => relation(item.id, id, type, false)).join('\n')}
609${added.map(id => relation(item.id, id, type, true)).join('\n')}
610${logs.join('\n')}
611UPDATE items SET ${closes ? "status='done', " : ''}updated_at=${NOW} WHERE id=${q(item.id)};
612COMMIT;`,
613    notes,
614  }
615}
616
617/**
618 * The script replacing an item's checklist with `texts`, in order. An entry whose text was already on the
619 * list keeps its tick, so rewording one item or adding another never unchecks the rest.
620 */
621export function setChecklist(actor: string, item: Item, texts: string[]): { script: string; notes: string[] } {
622  const next = setChecklistPreview(item, texts)
623  const same = next.length === item.checklist.length && next.every((c, i) => c.text === item.checklist[i]!.text)
624  if (same) return { script: '', notes: [] }
625  const notes = [next.length ? `checklist set (${next.length} item${next.length === 1 ? '' : 's'})` : 'checklist cleared']
626  const back = (from: Check[], to: Check[]) => `${guard(checksAre(item.id, from), changedSince(item.id, 'checklist'))}\n${writeChecks(item.id, to)}`
627  return {
628    script: `${BEGIN}
629${writeChecks(item.id, next)}
630${activity(item.id, actor, 'edit', notes[0]!, { undo: back(next, item.checklist), redo: back(item.checklist, next) })}
631COMMIT;`,
632    notes,
633  }
634}
635
636/** The checklist `setChecklist` would leave, ticks carried over, without writing it. */
637export function setChecklistPreview(item: Item, texts: string[]): Check[] {
638  const wasDone = new Set(item.checklist.filter(c => c.done).map(c => c.text))
639  return texts.map((text, i) => ({ n: i + 1, text, done: wasDone.has(text) }))
640}
641
642/** The script ticking (or unticking) checklist entries `ns`; entries already so are left alone. */
643export function check(actor: string, item: Item, ns: number[], done: boolean): { script: string; notes: string[] } {
644  const changing = item.checklist.filter(c => ns.includes(c.n) && c.done !== done)
645  const notes = changing.map(c => `${done ? 'checked' : 'unchecked'} ${c.n}. ${c.text}`)
646  if (notes.length === 0) return { script: '', notes }
647  return {
648    script: `${BEGIN}
649UPDATE checks SET done=${done ? 1 : 0} WHERE item_id=${q(item.id)} AND n IN (${changing.map(c => c.n).join(', ')});
650${changing.map((c, i) => activity(item.id, actor, 'edit', notes[i]!, { undo: tick(item.id, c.n, done, c.done), redo: tick(item.id, c.n, c.done, done) })).join('\n')}
651UPDATE items SET updated_at=${NOW} WHERE id=${q(item.id)};
652COMMIT;`,
653    notes,
654  }
655}
656
hooks/pane.tsx 2083 lines
1import type { Elements, EventOf, RenderChildren, RenderElement } from 'claude-code'
2
3import type { Checks, Draft, Item, Pr, Priority, Refs, Snapshot, Status, View } from '../types'
4import * as db from './db'
5import { kids, paint } from './paint'
6import {
7  backlog, dateOf, daysBetween, find, GLYPH, isLate, lastChange, stackFrom, stackBelow, stackText, SECTIONS, sectionFor, openPrOf, stackedOn, homesFor, isAgent, KINDS, TYPES, PRIORITIES, isMessage, isStale, LABEL, linksOf, marks, matches, parseQuery, path, progress, refsFor, STATUSES, statusOf, timeline, unread, USER,
8  subtree, waitingOn, upOf, tasksIn, spanOf, targetOf, releaseOf, unreleased, shipNote, releasesOf, nextVersion, isDropped, WONTDO_GLYPH, treeRows as treeRowsOf, timelineRows, childrenOf,
9} from './model'
10
11export const COLOR: Record<Status, string> = { todo: 'gray', in_progress: 'yellow', blocked: 'red', review: 'blue', done: 'green' }
12// Urgent priorities stand out on a card; the rest of the marks read dim.
13export const PRIORITY_COLOR: Record<Priority, string | undefined> = { p0: 'red', p1: 'yellow', p2: undefined, p3: 'gray' }
14// The views, in the order `v` steps through them.
15// The tabs in the order a piece of work lives through them: filed, planned, scheduled, done, shipped.
16const VIEWS: [View, string][] = [['inbox', 'Inbox'], ['plan', 'Plan'], ['roadmap', 'Roadmap'], ['board', 'Board'], ['releases', 'Releases']]
17// A pull request's checks, as marked next to it.
18const CHECKS: Record<Checks, string> = { none: '', pending: '… checks running', pass: '✓ checks', fail: '✗ checks failing' }
19const CHECKS_COLOR: Record<Checks, string | undefined> = { none: undefined, pending: 'yellow', pass: 'green', fail: 'red' }
20// Checks as one mark after a PR number on a board row.
21const CHECK_MARK: Record<Checks, string> = { none: '', pending: ' …', pass: ' ✓', fail: ' ✗' }
22// Docked cards: the fewest body rows that hold a board above a card.
23const DOCK_MIN_ROWS = 30
24
25/**
26 * How many cards of each column fit in `budget` rows, given the rows each card takes (a card wraps in a
27 * narrow column), by column, beside one another (`isWide`) or stacked: work under way and in review
28 * first, then blocked, todo and done. A column cut short spends a row on its "… more".
29 */
30export function columnCaps(heights: Record<Status, number[]>, budget: number, isWide: boolean): Record<Status, number> {
31  const caps = { todo: 0, in_progress: 0, blocked: 0, review: 0, done: 0 } as Record<Status, number>
32  // The cards from the top of a column that fit in `room` rows, and the rows they take.
33  const fit = (rows: number[], room: number) => {
34    let n = 0
35    let used = 0
36    while (n < rows.length && used + rows[n]! <= room) used += rows[n++]!
37    return { n, used }
38  }
39  const total = (rows: number[]) => rows.reduce((sum, one) => sum + one, 0)
40  if (isWide) {
41    for (const status of STATUSES)
42      caps[status] = total(heights[status]) <= budget - 1 ? heights[status].length : fit(heights[status], Math.max(0, budget - 2)).n
43    return caps
44  }
45  // Each non-empty column's heading and a row for its "… more" in case it is cut; the empty ones share a line.
46  const filled = STATUSES.filter(status => heights[status].length > 0).length
47  let left = budget - 2 * filled - (filled < STATUSES.length ? 1 : 0)
48  for (const status of ['in_progress', 'review', 'blocked', 'todo', 'done'] as Status[]) {
49    const { n, used } = fit(heights[status], Math.max(0, left))
50    caps[status] = n
51    // A column cut short takes what is left: the columns after it wait their turn.
52    left = n < heights[status].length ? 0 : left - used
53  }
54  return caps
55}
56
57// Done shows the tasks finished in the last RECENT_DAYS, at least DONE_MIN and at most DONE_MAX of them.
58const RECENT_DAYS = 7
59const DONE_MIN = 3
60const DONE_MAX = 8
61// Rows of the pane that aren't the board's: the header, the footer and a spare.
62const BOARD_CHROME = 5
63
64// The cells of the header's progress bar.
65const PROGRESS_BAR = 8
66
67/** A bar of `cells` cells, filled for `done` of `total`: the filled part and the rest. */
68export function progressBar(done: number, total: number, cells: number): { done: string; left: string } {
69  const filled = total > 0 ? Math.round((done / total) * cells) : 0
70  return { done: '█'.repeat(filled), left: '░'.repeat(cells - filled) }
71}
72
73/** The first of `hints` that fit in `rows` rows of `width`, each whole and joined by " · ". */
74export function fitHints(hints: string[], width: number, rows: number): string[] {
75  for (let n = hints.length; n > 1; n--) {
76    let lines = 1
77    let used = 0
78    for (const [i, hint] of hints.slice(0, n).entries()) {
79      const w = hint.length + (i < n - 1 ? 2 : 0)
80      if (used > 0 && used + 1 + w > width) (lines++, (used = w))
81      else used += (used > 0 ? 1 : 0) + w
82    }
83    if (lines <= rows) return hints.slice(0, n)
84  }
85  return hints.slice(0, 1)
86}
87
88// An item with nothing above it, for walks that start from one that may be missing.
89const EMPTY = { parent: null, milestone: null } as Item
90
91/**
92 * A table's column: its header, its width (or `fill`: what the others leave), and when it goes as the room
93 * runs short (the highest `drop` first; 0 never). `align: 'right'` for counts; `most`: the widest a fill column needs.
94 */
95export type TableColumn = { key: string; label: string; width: number | 'fill'; drop: number; align?: 'right'; most?: number }
96// A wide board card's title wraps to this many lines at most.
97const TITLE_LINES = 2
98// The fewest columns a table's fill column keeps.
99const FILL_MIN = 16
100
101/** The columns that fit in `room`, one apart, each with its width: the fill column takes what is left. */
102export function fitColumns(columns: TableColumn[], room: number): (TableColumn & { width: number })[] {
103  let kept = [...columns]
104  // The fill column (a title) keeps at least two fifths of the room.
105  const least = Math.max(FILL_MIN, Math.floor(room * 0.4))
106  const need = (list: TableColumn[]) => list.reduce((sum, one) => sum + (one.width === 'fill' ? least : one.width), 0) + list.length - 1
107  while (need(kept) > room) {
108    const next = kept.filter(one => one.drop > 0).sort((a, b) => b.drop - a.drop)[0]
109    if (!next) break
110    kept = kept.filter(one => one !== next)
111  }
112  const fixed = kept.reduce((sum, one) => sum + (one.width === 'fill' ? 0 : one.width), 0) + kept.length - 1
113  return kept.map(one => ({ ...one, width: one.width === 'fill' ? Math.max(least, Math.min(room - fixed, one.most ?? Infinity)) : one.width }))
114}
115
116/** `text` in a cell `width` wide: cut with … when longer, padded (on the left when `right`) when shorter. */
117export const cellOf = (text: string, width: number, align?: 'right') =>
118  [...text].length > width ? `${[...text].slice(0, Math.max(0, width - 1)).join('')}…` : align === 'right' ? text.padStart(width) : text.padEnd(width)
119
120// The outline of the frame in use, when a card is docked under the list.
121const ACTIVE = 'cyan'
122// Docked, the frame the wheel and keys move is outlined in ACTIVE, dimmed; the one under the pointer lights
123// up in it, so the bright outline follows the mouse (and the divider's arrows light under it).
124// Under the pointer, a board card's faint background, a shade over the pane's (a 256-colour grey, as most
125// terminals draw it alike).
126const LIT_CARD = 'ansi256(237)'
127const LIT = { borderColor: ACTIVE, borderDimColor: false } as const
128const LIT_TEXT = { color: ACTIVE, dimColor: false, bold: true } as const
129// Docked: the list's fewest rows (its frame included), the divider's row, and how far one press moves it.
130const LIST_MIN = 8
131const DIVIDER_ROWS = 1
132const SPLIT_STEP = 2
133
134// The space between board columns side by side, and what a column's frame (border and padding) takes across.
135const COLUMN_GAP = 1
136const FRAME = 4
137
138/**
139 * The width of each board column side by side in `width`: a column with a fixed width (`fixed`, 0 for
140 * none: an empty column's heading) takes that, and the others share what is left evenly.
141 */
142export function columnWidths(fixed: Record<Status, number>, width: number, gap: number): Record<Status, number> {
143  const free = STATUSES.filter(status => fixed[status] === 0)
144  const taken = STATUSES.reduce((sum, status) => sum + fixed[status], 0) + gap * (STATUSES.length - 1)
145  const share = free.length ? Math.floor((width - taken) / free.length) : 0
146  return Object.fromEntries(STATUSES.map(status => [status, fixed[status] || share])) as Record<Status, number>
147}
148
149/** The first of `list` whose rows (`heights`, one each) fit in `room`. */
150export function fitRows<T>(list: T[], heights: number[], room: number): T[] {
151  let used = 0
152  let n = 0
153  while (n < list.length && used + heights[n]! <= room) used += heights[n++]!
154  return list.slice(0, Math.max(1, n))
155}
156
157// A backlog row's picker, priority and hand-off beside its title, with the gaps between them.
158const BACKLOG_EDGES = 1 + 5 + 12 + 3
159
160/** The rows `text` takes wrapped at word boundaries to `width` columns, as the terminal draws it. */
161export function rowsOf(text: string, width: number): number {
162  if (width < 1) return 1
163  let rows = 1
164  let col = 0
165  for (const word of text.split(' ')) {
166    const length = [...word].length
167    if (col === 0) col = length
168    else if (col + 1 + length <= width) col += 1 + length
169    else {
170      rows++
171      col = length
172    }
173    // A word longer than the line is broken across rows.
174    while (col > width) {
175      rows++
176      col -= width
177    }
178  }
179  return rows
180}
181
182export const HOTKEY: Record<Status, string> = { todo: 't', in_progress: 'p', blocked: 'b', review: 'r', done: 'd' }
183
184/** What the pane draws from, read by the hooks module. */
185export type PaneState = {
186  snap: Snapshot
187  mode: View
188  pick: string | null
189  trouble: string | null
190  known: Refs
191  isIgnoreOffered: boolean
192  isRequesting: boolean
193  /** The board's filter as typed; empty for none. */
194  filter: string
195  /** Whether the filter's field is open. */
196  isFiltering: boolean
197  /** Whether the field filing to the inbox is open. */
198  isFiling: boolean
199  /** Whether the Releases tab asks for the version to release. */
200  isReleasing: boolean
201  /** The roadmap's zoom: 0 shows all the dated work; each step closer around today. */
202  zoom: number
203  /** The inbox item whose row asks where it goes or why it's dropped. */
204  triaging: { id: string; mode: 'into' | 'drop' } | null
205  /** Whether the board's Done column shows all done work, not just the recent. */
206  isDoneOpen: boolean
207  /** Milestones and epics folded otherwise than by default: a finished one opened, an open one folded. */
208  flipped: string[]
209  /** The new-item form, while it is open. */
210  draft: Draft | null
211  /** Whether the open card shows its fields for editing. */
212  isEditing: boolean
213  /** The item waiting on a yes before it is handed to Claude. */
214  handing: string | null
215  /** The item waiting on a yes before it is approved and its pull request merged. */
216  merging: string | null
217  /** The item whose card asks before merging its stack of PRs. */
218  stacking: string | null
219  /** What a stack being merged is doing now; empty when none is. */
220  stackRun: string
221  /** Backlog rows picked to run in parallel. */
222  picked: string[]
223  /** The tasks waiting on a yes before they are handed out to run in parallel. */
224  parallelAsk: string[] | null
225  /** Whether a comment on an agent's card starts a turn at once. */
226  commentTurns: boolean
227  /** The task whose card asks for its release note, having just been set done. */
228  noting: string | null
229  /** The task whose card asks why it is dropped (won't do), while it does. */
230  dropping: string | null
231  /** How far the open card is scrolled, as asked. */
232  scrolledTo: number
233  /** Where the tab showing is scrolled to, in rows from its top. */
234  viewScrolledTo: number
235  /** Just opened, the docked list holds the open item's row in sight (until the wheel moves it). */
236  isRevealing?: boolean
237  /** With a card docked under the list, which of the two the wheel last moved (the card when it opens). */
238  region: 'list' | 'card'
239  /** The list's rows with a card docked, as the person set them with the divider; null sizes by the card. */
240  split: number | null
241  /** The clock, for stale claims; 0 when it can't be read. */
242  now: number
243}
244
245/**
246 * What a press does, bound by the hooks module: the pane is drawing only (an engine handle never
247 * crosses into another file), and every write or move goes back through these.
248 */
249export type PaneActions = {
250  open: (id: string | null) => void
251  closeDetail: (id: string) => void
252  userAct: (a: { action: string; [field: string]: unknown }) => void
253  /** Asks to confirm approving an item and merging its pull request (null drops the question). */
254  askMerge: (id: string | null) => void
255  /** Approves an item in review, merging `pr` first when given. */
256  approve: (item: Item, pr?: Pr) => void
257  /** Asks to confirm handing an item to Claude (null drops the question). */
258  askHand: (id: string | null) => void
259  handToClaude: (item: Item) => void
260  requestChanges: (item: Item, what: string) => void
261  setView: (mode: View) => void
262  setRequesting: (isOn: boolean) => void
263  setFilter: (text: string) => void
264  setFiltering: (isOn: boolean) => void
265  setFiling: (isOn: boolean) => void
266  setReleasing: (isOn: boolean) => void
267  setZoom: (zoom: number) => void
268  /** Sets the list's rows over a docked card (the divider); null to size them by the card again. */
269  setSplit: (rows: number | null) => void
270  setTriaging: (one: { id: string; mode: 'into' | 'drop' } | null) => void
271  /** Opens the Releases tab on `version`, unfolded. */
272  showRelease: (version: string) => void
273  /** Runs ship from the board: the release PR for `version`, or (`publish`, once it has merged) its tag and release. */
274  release: (version: string, publish: boolean) => void
275  /** Files `title` to the inbox. */
276  file: (title: string) => void
277  setDoneOpen: (isOn: boolean) => void
278  /** Folds or unfolds a milestone or epic in the tree and the timeline. */
279  toggleFold: (id: string) => void
280  /** Opens the new-item form (under `parent` when given), changes its choices, or closes it (null). */
281  setDraft: (draft: Draft | null) => void
282  create: (draft: Draft, title: string) => void
283  setEditing: (isOn: boolean) => void
284  /** Takes back the person's last change, or the logged entries `ids`. */
285  undo: (ids?: number[]) => void
286  /** Marks every comment on the board as read by the person. */
287  markAllRead: () => void
288  /** Asks to confirm merging the stack on an item's card (null drops the question). */
289  askStack: (id: string | null) => void
290  /** Merges a stack of PRs, bottom first. */
291  mergeStack: (stack: Pr[]) => void
292  /** Sets which backlog rows are picked to run in parallel. */
293  setPicked: (ids: string[]) => void
294  /** Asks to confirm handing tasks out to run in parallel (null drops the question). */
295  askParallel: (ids: string[] | null) => void
296  /** Hands tasks out to run in parallel, each to its own agent in its own worktree. */
297  runParallel: (ids: string[]) => void
298  /** Posts the person's comment on an item (starting a turn when set to). */
299  comment: (item: Item, body: string) => void
300  /** Puts a prompt about an item in the prompt box. */
301  askClaude: (item: Item) => void
302  /** Sets whether comments on an agent's card start a turn. */
303  setCommentTurns: (isOn: boolean) => void
304  /** Asks for a task's release note on its card (null drops the question). */
305  setNoting: (id: string | null) => void
306  setDropping: (id: string | null) => void
307  /** Moves the keyboard ring to an element of the pane. */
308  focus: (key: string) => void
309  addIgnore: () => void
310  dismissIgnore: () => void
311}
312
313/** Breaks text into lines of at most `width` cells at spaces, keeping its own line breaks. */
314export function wrap(text: string, width: number): string[] {
315  const out: string[] = []
316  for (const para of text.split('\n')) {
317    let line = ''
318    for (const word of para.split(/ +/)) {
319      if (line && line.length + 1 + word.length > width) {
320        out.push(line)
321        line = ''
322      }
323      line = line ? `${line} ${word}` : word
324      while (line.length > width) {
325        out.push(line.slice(0, width))
326        line = line.slice(width)
327      }
328    }
329    out.push(line)
330  }
331  return out
332}
333
334/**
335 * Draws the board, the tree, or an open card from `els` (the surface's elements), answering the
336 * tree and how far the open card can scroll.
337 */
338export function drawPane(
339  els: Elements[keyof Elements], e: EventOf['ui.render'], state: PaneState, act: PaneActions,
340): { node: RenderElement; scrollMax: number; viewScrollMax: number; listEnd: number } {
341  const { Box, Text, Button, Link } = els
342  const Input = 'Input' in els ? els.Input : undefined
343  /**
344   * A window `room` lines tall over `blocks`, from line `at`: blocks wholly inside it as they are, and of a
345   * block cut by the top or bottom edge (a wrapped description, an activity entry), the lines of it inside,
346   * drawn plain. Scrolling so moves a line at a time. Blocks are measured as drawn, `width` wide.
347   */
348  const windowOf = (blocks: { key: string; node: unknown }[], at: number, room: number, width: number) => {
349    const measured = blocks.map(one => ({ ...one, lines: Math.max(1, paint(one.node as never, width).length) }))
350    const total = measured.reduce((sum, one) => sum + one.lines, 0)
351    const nodes: unknown[] = []
352    let line = 0
353    for (const one of measured) {
354      const top = line
355      const bottom = line + one.lines
356      line = bottom
357      if (bottom <= at || top >= at + room) continue
358      if (top >= at && bottom <= at + room) nodes.push(one.node)
359      else
360        // Cut by an edge: the lines of it in the window, drawn as the painter lays them out.
361        paint(one.node as never, width)
362          .slice(Math.max(0, at - top), Math.min(one.lines, at + room - top))
363          .forEach((text, i) => nodes.push(<Text key={`${one.key}-cut-${i}`}>{text.replace(/^ +/, lead => '\u00a0'.repeat(lead.length)) || ' '}</Text>))
364    }
365    const above = Math.min(at, total)
366    const below = Math.max(0, total - at - room)
367    return { nodes, total, above, below }
368  }
369  const Select = 'Select' in els ? els.Select : undefined
370  const { snap, mode, pick, trouble, known, isIgnoreOffered, isRequesting, now, filter, isFiltering, isFiling, isReleasing, zoom, triaging, region, split, isDoneOpen, flipped, draft, isEditing, handing, merging, noting, dropping, commentTurns, stacking, stackRun, picked, parallelAsk } = state
371  // Handing over starts Claude working, so it takes a yes: no key or stray Enter does it in one go.
372  const confirmHand = (one: Item) => (
373    <Box key={`hand-confirm-${one.id}`} flexDirection="row" columnGap={1}>
374      <Text color="yellow">Hand {one.id} to Claude? It starts on it now.</Text>
375      <Button key="hand-yes" label="Yes, hand it over" onPress={() => act.handToClaude(one)} />
376      <Button key="hand-cancel" label="Cancel" onPress={() => act.askHand(null)} />
377    </Box>
378  )
379  const query = parseQuery(filter)
380  // What the filter lets through: everything without one; with one, what matches and, in the tree, what holds it.
381  const isShown = (item: Item) => !query || matches(snap, item, query)
382  const items = snap.items
383  const paneWidth = (e.props as { bodyColumns?: number }).bodyColumns ?? e.viewport?.columns ?? 100
384  // Inline the pane gets about a third of the screen, so an open card there spends as few rows as it can.
385  const isCompact = e.surface === 'terminal' && (e.props as { placement?: string }).placement === 'inline'
386  // On the terminal, the rows the pane's body has; elsewhere the tree just grows.
387  const bodyRows = (e.props as { scroll?: { bodyRows?: number } }).scroll?.bodyRows
388  // An open card docks under the board when the pane has room for both; the board keeps the top part.
389  // (Only a card for an item that is there: one removed meanwhile docks nothing.)
390  const isDocked = Boolean(pick && find(items, pick)) && !isCompact && !draft && bodyRows !== undefined && bodyRows >= DOCK_MIN_ROWS
391  // Docked, the list is framed like the card under it (two cards stacked): its border and padding take four columns.
392  // Docked in the terminal, the list is framed as a card, whether one is open under it or not (it then
393  // takes the whole area); its border and padding take four columns, and two rows.
394  const isFramed = isDocked || (e.surface === 'terminal' && (e.props as { placement?: string }).placement === 'dock' &&
395    bodyRows !== undefined && !draft && !(pick && find(items, pick)))
396  const width = isFramed ? paneWidth - 4 : paneWidth
397  // Five columns side by side need room for a readable title in each; narrower, they stack.
398  const isWide = width >= 100
399  // A tab longer than its room scrolls (the wheel moves it): under the header, or docked, in its frame over a card.
400  const canScroll = e.surface === 'terminal' && bodyRows !== undefined && !draft && !isCompact && (!(pick && find(items, pick)) || isDocked)
401  // Pressing the open card again closes it.
402  const choose = (id: string | null) => () => (id !== null && id === pick ? act.closeDetail(id) : act.open(id))
403  const badge = (item: Item) => {
404    const count = unread(snap, item.id, USER).length
405    return count ? ` ● ${count}` : ''
406  }
407
408  // What a card says beside its title, each piece led by a space.
409  // Merged work the next release would ship, for the cards that say so.
410  const unreleasedIds = new Set(unreleased(snap, known).map(one => one.id))
411  const piecesOf = (item: Item) => {
412    const list = item.checklist ?? []
413    const part = item.kind === 'task' ? undefined : progress(items, item)
414    const tags = marks(item)
415    const waits = waitingOn(items, item).map(one => one.id)
416    // Work in review shows the pull request an Approve would merge.
417    const pr = statusOf(items, item) === 'review' ? openPrOf(known, item) : undefined
418    return {
419      pr,
420      tag: tags.length || isDropped(item) ? ` ${[...(isDropped(item) ? [`${WONTDO_GLYPH} won't do`] : []), ...tags].join(' ')}` : '',
421      // A milestone's or epic's tasks done (a bare count), a task's checklist ticked (☑).
422      ticks: part ? ` ${part.done}/${part.total}` : list.length ? ` ☑${list.filter(c => c.done).length}/${list.length}` : '',
423      prTag: pr ? ` PR #${pr.number}${CHECK_MARK[pr.checks]}` : '',
424      wait: waits.length ? ` ⧗${waits.join(',')}` : '',
425      who: item.assignee ? ` @${item.assignee}` : '',
426      stale: isStale(item, now) ? ' ⌛stale' : '',
427      // Past when it was due, its own date or one above it.
428      late: isLate(items, item, now) ? ' ⚠late' : '',
429      news: badge(item),
430      // Done work says where it went: the version it shipped in, or that the next release will carry it.
431      ship: item.kind === 'task' && item.status === 'done' ? (releaseOf(snap, item.id) ? ` v${releaseOf(snap, item.id)!.version}` : unreleasedIds.has(item.id) ? ' unreleased' : '') : '',
432    }
433  }
434  type Slots = Record<Exclude<keyof ReturnType<typeof piecesOf>, 'pr'> | 'id', number>
435  const SLOT_KEYS = ['tag', 'ticks', 'prTag', 'wait', 'who', 'stale', 'late', 'ship', 'news'] as const
436  // A name is given at most this much of a stacked row's slots; a longer one is cut.
437  const WHO_SLOT = 19
438  /** The width of each piece's slot over `list`: the widest of each, so stacked rows line them up. */
439  const slotsOf = (list: Item[]): Slots => {
440    const slots = Object.fromEntries([...SLOT_KEYS, 'id'].map(key => [key, 0])) as Slots
441    for (const one of list) {
442      slots.id = Math.max(slots.id, one.id.length)
443      const pieces = piecesOf(one)
444      for (const key of SLOT_KEYS) slots[key] = Math.max(slots[key], key === 'who' ? Math.min(WHO_SLOT, pieces.who.length) : pieces[key].length)
445    }
446    return slots
447  }
448
449  /**
450   * How a card is laid out in `room` columns. Stacked, given `slots`, on one line: the title, then each
451   * piece in its slot so the rows line up; else on one line, the title cut when it all doesn't fit. In a
452   * board column side by side, a card of its own: the title on up to TITLE_LINES lines, then its details
453   * on a line under it. Only the title and a long name are ever cut.
454   */
455  const cardLayout = (item: Item, room: number, isStacked: boolean, slots?: Slots) => {
456    const { tag, ticks, pr, prTag, wait, who: fullWho, stale, late, ship, news } = piecesOf(item)
457    // (⌛ takes two cells.)
458    const others = tag.length + ticks.length + prTag.length + wait.length + stale.length + (stale ? 1 : 0) + late.length + ship.length + news.length
459    const head = item.id.length + 1
460    const cutWho = (whoRoom: number) => (fullWho.length <= whoRoom ? fullWho : whoRoom >= 5 ? `${fullWho.slice(0, whoRoom - 1)}…` : '')
461    const cutTitle = (titleRoom: number) => (item.title.length <= titleRoom ? item.title : `${item.title.slice(0, Math.max(1, titleRoom - 1))}…`)
462    const bits = { tag, ticks, pr, prTag, wait, stale, late, ship, news }
463    const slotted = slots ? SLOT_KEYS.reduce((sum, key) => sum + slots[key], 0) : 0
464    if (isStacked && slots && room - slots.id - 1 - slotted >= 16) {
465      const fit = (text: string, key: keyof Slots) => (text.length > slots[key] ? `${text.slice(0, slots[key] - 1)}…` : text).padEnd(slots[key])
466      const lead = slots.id + 1
467      const title = cutTitle(room - lead - slotted)
468      return {
469        id: item.id.padEnd(slots.id), tag: fit(tag, 'tag'), ticks: fit(ticks, 'ticks'), pr, prTag: fit(prTag, 'prTag'), wait: fit(wait, 'wait'), who: fit(fullWho, 'who'),
470        stale: fit(stale, 'stale'), late: fit(late, 'late'), ship: fit(ship, 'ship'), news: fit(news, 'news'), title, pad: room - lead - title.length - slotted, rows: 1,
471      }
472    }
473    if (!isStacked) {
474      // The title wrapped at words under itself, past the id; the last line it gets cut short.
475      const lines = wrap(item.title, Math.max(4, room - head)).slice(0, TITLE_LINES + 1)
476      const kept = lines.slice(0, TITLE_LINES)
477      if (lines.length > TITLE_LINES) kept[TITLE_LINES - 1] = cellOf(`${kept[TITLE_LINES - 1]} ${lines[TITLE_LINES]}`, room - head).trimEnd()
478      // The details sit under the title, past the id too.
479      const who = cutWho(Math.max(0, room - head - others + 1))
480      const details = others + who.length - 1
481      // Every line padded out to the column's width, so the card is a solid block when lit.
482      const across = Math.max(1, room - head)
483      const fill = (text: string) => `${text}${'\u00a0'.repeat(Math.max(0, across - [...text].length))}`
484      return {
485        ...bits, title: kept.map(fill).join(`\n${'\u00a0'.repeat(head)}`), who, pad: 0, isSplit: details > 0,
486        tail: details > 0 && details % across ? across - (details % across) : 0,
487        rows: kept.length + (details > 0 ? Math.ceil(details / across) : 0),
488      }
489    }
490    // Too narrow for a title beside its details, a line keeps the title alone.
491    if (room - head - others < 6 && head + item.title.length + others + fullWho.length > room)
492      return { tag: '', ticks: '', pr: undefined, prTag: '', wait: '', stale: '', late: '', ship: '', news: '', title: cutTitle(room - head), who: '', pad: 0, rows: 1 }
493    if (head + item.title.length + others + fullWho.length <= room) {
494      const pad = isStacked ? room - head - item.title.length - others - fullWho.length : 0
495      return { ...bits, title: item.title, who: fullWho, pad, rows: 1 }
496    }
497    // A readable title comes first: a long name is cut short to make room for it.
498    const who = cutWho(Math.max(0, Math.min(18, room - head - others - 24)))
499    const title = cutTitle(room - head - others - who.length)
500    return { ...bits, title, who, pad: Math.max(0, room - head - title.length - others - who.length), rows: 1 }
501  }
502  const card = (item: Item, room: number, isStacked: boolean, slots?: Slots) => {
503    const laid = cardLayout(item, room, isStacked, slots)
504    const { title, tag, ticks, pr, prTag, wait, who, stale, late, ship, news, pad } = laid
505    const isSplit = 'isSplit' in laid && laid.isSplit
506    const tail = 'tail' in laid ? laid.tail : 0
507    const head = item.id.length + 1
508    const id = 'id' in laid && laid.id ? laid.id : item.id
509    // Under the pointer the button inverts, swapping each piece's colour and background: so on hover each takes
510    // its own colour as its background and a faint grey as its colour, and the inverted card reads as its
511    // pieces in their colours on one faint block.
512    const lit = (colour = 'text') => ({ color: LIT_CARD, backgroundColor: colour, dimColor: false })
513    const isOpen = pick === item.id
514    // A detail line leads with its first detail, its space dropped, under the title.
515    let isFirst = isSplit
516    const detail = (text: string) => {
517      if (!text || !isFirst) return text
518      isFirst = false
519      return text.slice(1)
520    }
521    return (
522      // A keyed box of its own: the card's hover scope.
523      <Box key={`card-box-${item.id}`}>
524        <Button key={`card-${item.id}`} plain onPress={choose(item.id)}>
525          <Text hover={lit('inactive')} dimColor={!isOpen} inverse={isOpen} bold={isOpen}>
526            {id}
527          </Text>
528          <Text hover={lit()}> </Text>
529          {/* Each line starts a Text of its own (a line break, then its indent): the inversion under the
530              pointer takes a Text's first line only, so every line lights whole. */}
531          {title.split('\n').map((line, n) => [
532            ...(n ? [<Text key={`br-${n}`} hover={lit()}>{'\n'}</Text>, <Text key={`in-${n}`} hover={lit()}>{line.slice(0, head)}</Text>] : []),
533            <Text key={`title-${n}`} hover={lit(isDropped(item) ? 'inactive' : undefined)} bold={isOpen} dimColor={isDropped(item)} strikethrough={isDropped(item)}>
534              {n ? line.slice(head) : line}
535            </Text>,
536          ])}
537          {isSplit ? [
538            <Text key="br-details" hover={lit()}>{'\n'}</Text>,
539            <Text key="in-details" hover={lit()}>{'\u00a0'.repeat(head)}</Text>,
540          ] : <Text hover={lit()}>{' '.repeat(pad)}</Text>}
541          <Text hover={lit(PRIORITY_COLOR[item.priority])} color={PRIORITY_COLOR[item.priority]} bold={item.priority === 'p0'}>
542            {detail(tag)}
543          </Text>
544          <Text hover={lit('inactive')} dimColor>{detail(ticks)}</Text>
545          <Text hover={lit(pr ? CHECKS_COLOR[pr.checks] ?? 'green' : undefined)} color={pr ? CHECKS_COLOR[pr.checks] ?? 'green' : undefined}>{detail(prTag)}</Text>
546          <Text hover={lit('yellow')} color="yellow" dimColor>
547            {detail(wait)}
548          </Text>
549          <Text hover={lit('cyan')} color="cyan">{detail(who)}</Text>
550          <Text hover={lit('red')} color="red" dimColor>
551            {detail(stale)}
552          </Text>
553          <Text hover={lit('red')} color="red">{detail(late)}</Text>
554          <Text hover={lit(ship.trim() === 'unreleased' ? 'yellow' : 'green')} color={ship.trim() === 'unreleased' ? 'yellow' : 'green'} dimColor>
555            {detail(ship)}
556          </Text>
557          <Text hover={lit('magenta')} color="magenta" bold>
558            {detail(news)}
559          </Text>
560          {tail > 0 && <Text hover={lit()}>{'\u00a0'.repeat(tail)}</Text>}
561        </Button>
562      </Box>
563    )
564  }
565
566  // What Undo would take back: the person's last change still standing.
567  const undoable = lastChange(snap, USER)
568  const canUndo = undoable.length > 0
569  const unreadTotal = items.reduce((sum, item) => sum + unread(snap, item.id, USER).length, 0)
570  const nextView = VIEWS[(VIEWS.findIndex(([one]) => one === mode) + 1) % VIEWS.length]![0]
571  // Dropped work counts neither way: it is closed, but nothing was done.
572  const doneCount = items.filter(i => i.kind === 'task' && i.status === 'done' && !isDropped(i)).length
573  const taskCount = items.filter(i => i.kind === 'task' && !isDropped(i)).length
574  // The header: the views as tabs with the progress and unread count beside them, then the actions. They
575  // share a row where the pane is wide enough; else the actions take a second row of their own.
576  const bar = progressBar(doneCount, taskCount, PROGRESS_BAR)
577  // What waits in the inbox to be sorted.
578  const waiting = (snap.inbox ?? []).filter(one => one.state === 'open')
579  // What waits on the person's decision, each item once with every reason: work in review (as the Board's
580  // Review column has it), comments they haven't read, claims gone quiet, and work past its date.
581  const needs = items
582    .map(item => {
583      const why: string[] = []
584      if ((item.kind === 'task' && item.status === 'review') || (item.kind !== 'task' && isAgent(item.assignee) && statusOf(items, item) === 'review')) why.push('review')
585      const notRead = unread(snap, item.id, USER).length
586      if (notRead) why.push(`${notRead} unread`)
587      if (isStale(item, now)) why.push('stale claim')
588      if (isLate(items, item, now)) why.push('late')
589      return { item, why }
590    })
591    .filter(one => one.why.length > 0)
592  // A tab names what waits in it: the inbox its open items.
593  const tabLabel = (view: View, label: string) => (view === 'inbox' && waiting.length + needs.length ? `${label} ${waiting.length + needs.length}` : label)
594  const header = (
595    <Box flexDirection="row" columnGap={3} flexWrap="wrap">
596      <Box key="views" flexDirection="row" columnGap={2}>
597        <Box key="tabs" flexDirection="row" columnGap={1}>
598          {/* `v` steps to the next view: a hotkey held by a Button out of sight, so the tabs read clean. */}
599          <Box key="tab-next-key" display="none">
600            <Button key="tab-next" plain hotkey="v" label={nextView} onPress={() => act.setView(nextView)} />
601          </Box>
602          {VIEWS.map(([one, label]) => (
603            <Button key={`tab-${one}`} plain variant={mode === one ? 'primary' : 'secondary'} onPress={() => act.setView(one)}>
604              {mode === one ? (
605                <Text inverse bold>
606                  {` ${tabLabel(one, label)} `}
607                </Text>
608              ) : (
609                <Text dimColor>{` ${tabLabel(one, label)} `}</Text>
610              )}
611            </Button>
612          ))}
613        </Box>
614        <Text key="progress">
615          <Text color="green">{bar.done}</Text>
616          <Text dimColor>{bar.left}</Text> <Text dimColor>{doneCount}/{taskCount} done</Text>
617        </Text>
618        {unreadTotal > 0 && (
619          <Text color="magenta" bold>
620            ● {unreadTotal} unread
621          </Text>
622        )}
623        {stackRun ? <Text color="yellow">Merging a stack: {stackRun}</Text> : null}
624      </Box>
625      <Box key="actions" flexDirection="row" columnGap={1}>
626        {unreadTotal > 0 && <Button key="mark-read" label="Mark all read" onPress={() => act.markAllRead()} />}
627        {!isFiltering && <Button key="filter" label={filter ? `Filter: ${filter}` : 'Filter'} hotkey="f" variant={filter ? 'primary' : 'secondary'}
628          onPress={() => act.setFiltering(true)} />}
629        {filter && !isFiltering ? <Button key="filter-clear" label="Clear" onPress={() => act.setFilter('')} /> : null}
630        {canUndo && <Button key="undo" label="Undo" hotkey="z" onPress={() => act.undo()} />}
631        {/* With a card open, n adds under it (on the card's bar) instead. */}
632        {!draft && !pick && <Button key="new" label="New" hotkey="n" onPress={() => act.setDraft(newDraft(null))} />}
633        {!isFiling && <Button key="file" label="File…" hotkey="i" onPress={() => act.setFiling(true)} />}
634      </Box>
635    </Box>
636  )
637
638  const filterRow = isFiltering && Input && (
639    <Box key="filter-row" flexDirection="row" columnGap={1}>
640      <Input key="filter-input" label="Filter" value={filter} autoFocus submitLabel="apply"
641        placeholder="@claude #ui p0 bug review under:E3 words…" onSubmit={(value: string) => act.setFilter(value.trim())} />
642      <Button key="filter-cancel" label="Cancel" onPress={() => act.setFiltering(false)} />
643    </Box>
644  )
645
646  // Filing to the inbox: a line typed now, sorted later.
647  const fileRow = isFiling && Input && (
648    <Box key="file-row" flexDirection="row" columnGap={1}>
649      <Input key="inbox-input" label="File to the inbox" autoFocus submitLabel="file" placeholder="An idea, a bug, a 'we should…'; Enter files it"
650        onSubmit={(value: string) => {
651          if (value.trim()) act.file(value.trim())
652          act.setFiling(false)
653        }} />
654      <Button key="file-cancel" label="Cancel" onPress={() => act.setFiling(false)} />
655    </Box>
656  )
657  // What waits to be sorted, as a table: the narrowest columns go first as the room runs short.
658  const inboxColumns = fitColumns([
659    { key: 'id', label: 'ID', width: Math.max(2, ...waiting.map(one => one.id.length)), drop: 0 },
660    { key: 'title', label: 'Title', width: 'fill', drop: 0, most: Math.max(5, ...waiting.map(one => one.title.length)) },
661    { key: 'by', label: 'Filed by', width: Math.min(16, Math.max(8, ...waiting.map(one => one.author.length))), drop: 2 },
662    { key: 'at', label: 'When', width: 10, drop: 1 },
663  ], width - 2)
664  const inboxView = (
665    <Box flexDirection="column">
666      {needs.length > 0 && (
667        <Box key="needs" flexDirection="column" marginBottom={1}>
668          <Text bold>Needs you  <Text dimColor>{needs.length}</Text></Text>
669          {needs.map(({ item, why }) => {
670            // The reasons in a column of their own, so the titles line up.
671            const reasons = why.join(' · ').padEnd(Math.min(24, Math.max(...needs.map(one => one.why.join(' · ').length))))
672            const room = Math.max(8, width - item.id.length - 1 - reasons.length - 2)
673            return (
674              <Button key={`need-${item.id}`} plain onPress={choose(item.id)}>
675                <Text color={why.includes('late') ? 'red' : why.includes('review') ? 'blue' : 'magenta'}>{reasons}</Text>{'  '}
676                <Text dimColor>{item.id}</Text> {item.title.length > room ? `${item.title.slice(0, room - 1)}…` : item.title}
677              </Button>
678            )
679          })}
680        </Box>
681      )}
682      {needs.length > 0 && waiting.length > 0 && <Text bold>To sort  <Text dimColor>{waiting.length}</Text></Text>}
683      {waiting.length === 0 && <Text dimColor>Nothing filed to sort. Press i to file something for later.</Text>}
684      {waiting.length > 0 && (
685        <Text key="inbox-head" dimColor bold>{inboxColumns.map(one => cellOf(one.label, one.width)).join(' ')}</Text>
686      )}
687      {waiting.map(one => {
688        const value: Record<string, string> = { id: one.id, title: one.title, by: one.author, at: one.at.slice(0, 10) }
689        const asking = triaging?.id === one.id ? triaging.mode : null
690        return (
691          <Box key={`inbox-${one.id}`} flexDirection="column">
692            <Text>
693              {inboxColumns.map((column, i) => (
694                <Text key={`c-${column.key}`} dimColor={column.key !== 'title'}>
695                  {i ? ' ' : ''}
696                  {cellOf(value[column.key] ?? '', column.width)}
697                </Text>
698              ))}
699            </Text>
700            {/* Sorting it: into a new task or epic (the form, its title filled in), into existing work, or dropped. */}
701            {asking && Input ? (
702              <Box key={`triage-row-${one.id}`} flexDirection="row" columnGap={1}>
703                <Input key="triage-input" label={asking === 'into' ? 'Into' : "Drop, because"} autoFocus
704                  placeholder={asking === 'into' ? "an id, as a comment; or 'T12 checklist' for an entry" : 'why it won’t be done'}
705                  submitLabel={asking === 'into' ? 'join' : 'drop'}
706                  onSubmit={(value: string) => {
707                    const text = value.trim()
708                    const into = /^(\S+)(\s+checklist)?$/i.exec(text)
709                    if (asking === 'into' && into)
710                      act.userAct({ action: 'triage', id: one.id, into: into[1], ...(into[2] ? { fold: 'checklist' } : {}) })
711                    else if (asking === 'drop' && text) act.userAct({ action: 'triage', id: one.id, wontdo: text })
712                    act.setTriaging(null)
713                  }} />
714                <Button key="triage-cancel" label="Cancel" onPress={() => act.setTriaging(null)} />
715              </Box>
716            ) : (
717              <Box key={`triage-${one.id}`} flexDirection="row" columnGap={1} flexWrap="wrap">
718                <Button key={`to-task-${one.id}`} label="→ Task"
719                  onPress={() => act.setDraft({ kind: 'task', priority: 'p2', type: 'feature', parent: '', from: one.id, title: one.title })} />
720                <Button key={`to-epic-${one.id}`} label="→ Epic"
721                  onPress={() => act.setDraft({ kind: 'epic', priority: 'p2', type: 'feature', parent: '', from: one.id, title: one.title })} />
722                <Button key={`into-${one.id}`} label="Into…" onPress={() => act.setTriaging({ id: one.id, mode: 'into' })} />
723                <Button key={`drop-${one.id}`} label="Drop…" onPress={() => act.setTriaging({ id: one.id, mode: 'drop' })} />
724              </Box>
725            )}
726            {one.body ? (
727              <Text dimColor>
728                {'  '}
729                {one.body.length > width - 3 ? `${one.body.replace(/\s+/g, ' ').slice(0, width - 4)}…` : one.body.replace(/\s+/g, ' ')}
730              </Text>
731            ) : null}
732          </Box>
733        )
734      })}
735    </Box>
736  )
737
738  // Releases: what the next one would carry, then each version shipped, newest first and open, older folded.
739  const shippedVersions = releasesOf(snap)
740  const pendingNotes = unreleased(snap, known)
741  const suggested = nextVersion(shippedVersions[0]?.version, pendingNotes)
742  // A release under way: its PR open, or merged and waiting to be tagged and published.
743  const releasePrs = known.prs.filter(pr => pr.branch.startsWith('release-v'))
744  const openRelease = releasePrs.find(pr => pr.state === 'open')
745  const toPublish = releasePrs.find(pr => pr.state === 'merged' && !shippedVersions.some(one => `release-v${one.version}` === pr.branch))
746  const releaseLine = (text: string, key: string) => (
747    <Text key={key} dimColor={!/^\s*- /.test(text)}>
748      {text.length > width - 2 ? `${text.slice(0, width - 3)}…` : text || ' '}
749    </Text>
750  )
751  const releaseColumns = fitColumns([
752    { key: 'version', label: 'Version', width: Math.max(7, ...shippedVersions.map(one => one.version.length + 1)), drop: 0 },
753    { key: 'at', label: 'Date', width: 10, drop: 3 },
754    { key: 'tasks', label: 'Tasks', width: 5, drop: 2, align: 'right' },
755    { key: 'pr', label: 'PR', width: 6, drop: 1 },
756    { key: 'stable', label: '', width: 8, drop: 4 },
757  ], width - 4)
758  const releasesView = (
759    <Box flexDirection="column">
760      <Box key="unreleased-head" flexDirection="row" columnGap={1} flexWrap="wrap">
761        <Text bold>Unreleased</Text>
762        <Text dimColor>{pendingNotes.length ? `${pendingNotes.length} note${pendingNotes.length === 1 ? '' : 's'} merged since the last release` : 'nothing merged since the last release'}</Text>
763        {openRelease ? (
764          <Text color="yellow">Release PR #{openRelease.number} is open; merge it, then Tag and publish</Text>
765        ) : toPublish ? (
766          <Button key="publish" label={`Tag and publish ${toPublish.branch.slice('release-'.length)}`} variant="primary"
767            onPress={() => act.release(toPublish.branch.slice('release-v'.length), true)} />
768        ) : (
769          pendingNotes.length > 0 && !isReleasing && <Button key="release" label="Release…" onPress={() => act.setReleasing(true)} />
770        )}
771      </Box>
772      {isReleasing && Input && (
773        <Box key="release-row" flexDirection="row" columnGap={1}>
774          <Input key="release-version" label="Version" value={suggested} autoFocus submitLabel="open its PR"
775            onSubmit={(value: string) => (value.trim() ? act.release(value.trim(), false) : act.setReleasing(false))} />
776          <Button key="release-cancel" label="Cancel" onPress={() => act.setReleasing(false)} />
777        </Box>
778      )}
779      {SECTIONS.map(section => {
780        const some = pendingNotes.filter(task => sectionFor(task) === section)
781        return some.length ? (
782          <Box key={`pending-${section}`} flexDirection="column">
783            <Text dimColor>{section}</Text>
784            {some.map(task => releaseLine(`- ${task.note} (${task.id})`, `pending-${task.id}`))}
785          </Box>
786        ) : null
787      })}
788      {shippedVersions.length > 0 && (
789        <Text key="releases-head" dimColor bold>{`\u00a0\u00a0${releaseColumns.map(one => cellOf(one.label, one.width, one.align)).join(' ')}`}</Text>
790      )}
791      {shippedVersions.length === 0 && <Text dimColor>No releases yet. ship records each one; past versions are read from CHANGELOG.md.</Text>}
792      {shippedVersions.map((one, i) => {
793        const key = `v${one.version}`
794        const isOpen = (i === 0) !== flipped.includes(key)
795        return (
796          <Box key={`release-${one.version}`} flexDirection="column">
797            <Button key={`fold-${key}`} plain onPress={() => act.toggleFold(key)}>
798              <Text dimColor>{isOpen ? '▾' : '▸'}</Text>{' '}
799              {releaseColumns.map((column, n) => {
800                const value: Record<string, string> = {
801                  version: `v${one.version}`, at: one.at ?? '', tasks: one.tasks.length ? String(one.tasks.length) : '',
802                  pr: one.pr ? `#${one.pr}` : '', stable: known.stable === one.version ? 'stable ●' : '',
803                }
804                return (
805                  <Text key={`c-${column.key}`} bold={column.key === 'version'} color={column.key === 'stable' ? 'green' : undefined}
806                    dimColor={column.key !== 'version' && column.key !== 'stable'}>
807                    {n ? ' ' : ''}
808                    {cellOf(value[column.key] ?? '', column.width, column.align)}
809                  </Text>
810                )
811              })}
812            </Button>
813            {isOpen && one.notes.split('\n').filter(text => text.trim()).map((text, n) => releaseLine(`  ${text.replace(/^### /, '')}`, `${key}-${n}`))}
814          </Box>
815        )
816      })}
817    </Box>
818  )
819
820  const tasks = items.filter(item => item.kind === 'task' && isShown(item)).sort((a, b) => b.updated_at.localeCompare(a.updated_at))
821  // A milestone or epic handed over whole is reviewed as one: it waits in Review, where its card approves and merges it.
822  const scopes = items.filter(item => item.kind !== 'task' && isAgent(item.assignee) && statusOf(items, item) === 'review' && isShown(item))
823  const columns = Object.fromEntries(STATUSES.map(status =>
824    [status, [...(status === 'review' ? scopes : []), ...tasks.filter(task => task.status === status)]])) as Record<Status, Item[]>
825  // Side by side, an empty column takes its heading's width and the columns with cards share the rest.
826  // A heading is drawn with its jump key: "t: ○ Todo 0".
827  const headOf = (status: Status) => `${HOTKEY[status]}: ${GLYPH[status]} ${LABEL[status]} ${columns[status].length}`
828  const widths = columnWidths(
829    Object.fromEntries(STATUSES.map(status => [status, columns[status].length > 0 ? 0 : headOf(status).length + FRAME])) as Record<Status, number>,
830    width, COLUMN_GAP)
831  // Side by side, each column is framed: its border and padding take FRAME of its width.
832  const roomOf = (status: Status) => (isWide ? widths[status] - FRAME : width - 2)
833  // Docked, the board fits the rows above the card; side by side, each heading has its rule under it.
834  // Done shows the recent (the last week's, at least a few) unless opened; the rest are a press away.
835  const recentDone = columns.done.filter(task => now - Date.parse(task.updated_at) < RECENT_DAYS * 86_400_000).length
836  const doneClosed = Math.min(columns.done.length, Math.max(DONE_MIN, Math.min(recentDone, DONE_MAX)))
837  const doneShown = isDoneOpen ? columns.done.length : doneClosed
838  // Each card's rows; side by side and short of rows, on one line each (`isOneLine`).
839  const heightsOf = (isOneLine: boolean) => Object.fromEntries(STATUSES.map(status =>
840    [status, columns[status].slice(0, status === 'done' ? doneShown : undefined)
841      .map(task => cardLayout(task, roomOf(status), !isWide || isOneLine).rows)
842      // Done cut to its recent cards still has its "…N older" row to fit: a card too tall to place stands for it.
843      .concat(status === 'done' && columns.done.length > doneShown ? [Infinity] : [])])) as Record<Status, number[]>
844  const heights = heightsOf(false)
845  // Docked, the board fits the rows above the card; Done opened fills what the pane has. Side by side,
846  // each heading has its rule under it; stacked, the blocks have a blank row between them.
847  const budget = !isDocked && isDoneOpen && bodyRows ? bodyRows - BOARD_CHROME - (isWide ? 0 : STATUSES.length) : Infinity
848  const caps = budget !== Infinity
849    ? columnCaps(heights, isWide ? budget - 3 : budget, isWide)
850    : (Object.fromEntries(STATUSES.map(status => [status, status === 'done' ? doneShown : canScroll ? Infinity : 15])) as Record<Status, number>)
851  // Stacked, the empty columns fold into one line, and every card's pieces sit in slots shared by the board.
852  const empties = isWide ? [] : STATUSES.filter(status => columns[status].length === 0)
853  const stackSlots = isWide ? undefined : slotsOf(STATUSES.flatMap(status => columns[status].slice(0, caps[status])))
854  const heading = (status: Status) => (
855    <Button key={`col-${status}-head`} plain hotkey={HOTKEY[status]}
856      onPress={() => columns[status][0] && act.focus(`card-${columns[status][0]!.id}`)}>
857      <Text bold color={COLOR[status]}>
858        {GLYPH[status]} {LABEL[status]}
859      </Text>{' '}
860      <Text dimColor>{columns[status].length}</Text>
861    </Button>
862  )
863  const doneToggle = (isDoneOpen || columns.done.length > doneClosed) && (
864    <Button key="done-toggle" plain onPress={() => act.setDoneOpen(!isDoneOpen)}>
865      <Text dimColor>{isDoneOpen ? '· recent only' : '· show all'}</Text>
866    </Button>
867  )
868  // Side by side, where the open card's column was drawn from to hold it in sight: the wheel goes on from there.
869  let revealedFrom: number | null = null
870  // Drawn once the room it has is known (see viewSpace).
871  const drawBoard = () => (
872    <Box flexDirection={isWide ? 'row' : 'column'} gap={isWide ? COLUMN_GAP : isDocked ? 0 : 1}>
873      {empties.length > 0 && (
874        <Box key="col-empty" flexDirection="row" columnGap={1} flexWrap="wrap">
875          {empties.flatMap((status, i) => [...(i ? [<Text key={`col-empty-${i}`} dimColor>·</Text>] : []), heading(status)])}
876        </Box>
877      )}
878      {STATUSES.filter(status => !empties.includes(status)).map(status => {
879        const column = columns[status]
880        // Just opened and docked, the open card's column holds it in sight, Done past its recent ones too.
881        const at = isDocked && state.isRevealing && pick ? column.findIndex(task => task.id === pick) : -1
882        const capped = column.slice(0, Math.max(caps[status], at + 1))
883        // Side by side and scrolling, each column shows the cards from the scrolled-to one that fit.
884        let from = isWide && canScroll ? Math.min(wideFrom, lastFrom(status, capped.length)) : 0
885        if (isWide && canScroll && at >= 0) {
886          if (at < from) from = at
887          while (from < at && at >= from + cardsFrom(status, from)) from++
888          revealedFrom = from
889        }
890        const shown = isWide && canScroll ? capped.slice(from, from + cardsFrom(status, from)) : capped
891        return (
892          <Box key={`col-${status}`} flexDirection="column" width={isWide ? widths[status] : undefined}
893            // Side by side and scrolling, every column's frame reaches the foot of the list.
894            height={isWide && canScroll && viewSpace !== Infinity ? viewSpace : undefined}
895            {...(isWide ? { borderStyle: 'round', borderColor: COLOR[status], borderDimColor: true, paddingX: 1, hover: { borderDimColor: false } } : {})}>
896            {status === 'done' && doneToggle ? (
897              <Box key="col-done-top" flexDirection="row" columnGap={1}>
898                {heading(status)}
899                {doneToggle}
900              </Box>
901            ) : heading(status)}
902            {from > 0 && <Text key={`col-${status}-above`} dimColor>↑ {from} above</Text>}
903            {shown.map(task => card(task, roomOf(status), !isWide || isOneLine, stackSlots))}
904            {column.length > from + shown.length && (
905              <Text dimColor>
906                …{column.length - from - shown.length} {status === 'done' && !isDoneOpen ? 'older' : 'more'}
907              </Text>
908            )}
909          </Box>
910        )
911      })}
912    </Box>
913  )
914
915
916  // A finished milestone or epic is folded to its own line, an open one unfolded, each until pressed;
917  // with a filter typed, nothing is folded, so every match shows.
918  const hasKids = (item: Item) => item.kind !== 'task' && childrenOf(items, item.id).length > 0
919  // What holds the open card stays unfolded, so the card's row is always there to return to.
920  const holdsPick = new Set<string>()
921  for (let at = upOf(find(items, pick ?? undefined) ?? EMPTY); at; at = upOf(find(items, at) ?? EMPTY)) holdsPick.add(at)
922  const isFolded = (item: Item) =>
923    !query && hasKids(item) && !holdsPick.has(item.id) && (statusOf(items, item) === 'done') !== flipped.includes(item.id)
924  // The timeline shows epics under milestones, not tasks: there, only a milestone with epics folds.
925  const foldsInTimeline = (item: Item) => item.kind === 'milestone' && childrenOf(items, item.id).some(one => one.kind === 'epic')
926  const foldToggle = (item: Item, canFold = hasKids(item)) =>
927    canFold ? (
928      <Button key={`fold-${item.id}`} plain onPress={() => act.toggleFold(item.id)}>
929        <Text dimColor>{isFolded(item) ? '▸' : '▾'}</Text>
930      </Button>
931    ) : (
932      <Text key={`fold-${item.id}`}> </Text>
933    )
934  // Handing several out at once takes a yes, naming them and what waits.
935  const confirmParallel = (ids: string[], key: string) => {
936    const waits = ids.filter(id => { const one = find(items, id); return one && waitingOn(items, one).length > 0 })
937    return (
938      <Box key={key} flexDirection="row" columnGap={1} flexWrap="wrap">
939        <Text color="yellow">
940          Run {ids.join(', ')} at once, each by its own agent in its own worktree?{waits.length ? ` ${waits.join(', ')} ${waits.length === 1 ? 'starts' : 'start'} when what ${waits.length === 1 ? 'it waits' : 'they wait'} on is done.` : ''}
941        </Text>
942        <Button key="parallel-yes" label="Yes, start them" onPress={() => act.runParallel(ids)} />
943        <Button key="parallel-cancel" label="Cancel" onPress={() => act.askParallel(null)} />
944      </Box>
945    )
946  }
947
948  // The plan: milestones (by date, open first) with what targets them, then Unplanned: epics and tasks no
949  // milestone holds. There, a task nobody holds yet keeps the backlog's controls: a pick for running several
950  // at once, a priority picker and a hand-off.
951  // Finished tasks no epic or milestone holds fold behind one line at the foot of Unplanned, as finished
952  // epics do: open with its toggle (or a filter, or a card open on one of them).
953  const LOOSE = '_loose'
954  const isLooseDone = ({ item, depth }: { item: Item; depth: number }) => depth === 0 && item.kind === 'task' && item.status === 'done' && item.id !== pick
955  const showsLoose = Boolean(query) || flipped.includes(LOOSE)
956  const everyRow = treeRowsOf(items, isFolded).filter(({ item }) => !query || subtree(items, item.id).some(id => isShown(find(items, id)!)))
957  const looseDone = everyRow.filter(isLooseDone)
958  const allRows = showsLoose ? everyRow : everyRow.filter(row => !isLooseDone(row))
959  // Merged work the next release would ship.
960  const waitingRelease = new Set(unreleased(snap, known).map(one => one.id))
961  const firstUnplanned = allRows.findIndex(({ item, depth }) => depth === 0 && item.kind !== 'milestone')
962  const isTriage = (item: Item) => item.kind === 'task' && item.status === 'todo' && !item.assignee && !targetOf(items, item)
963  const unheld = allRows.filter(({ item }) => isTriage(item)).length
964  // The plan's rows: scrolled in the tab's window (docked over a card, in the list's frame).
965  const treeShown = allRows
966  // The plan as a table: a fold toggle and (in Unplanned) a pick in slots of their own, then the item's columns,
967  // the narrowest going first as the room runs short; a task nobody holds keeps a priority picker and a hand-off
968  // after its row.
969  // What an item shows in a column of the plan's table.
970  const planValue = (item: Item, key: string) => {
971    const p = progress(items, item)
972    const release = item.kind === 'task' ? releaseOf(snap, item.id) : undefined
973    const list = item.checklist ?? []
974    if (key === 'news') return badge(item).trim()
975    if (key === 'ship') return release ? `v${release.version}` : waitingRelease.has(item.id) ? 'unreleased' : ''
976    if (key === 'due') return item.due ?? ''
977    if (key === 'type') return item.kind === 'task' ? item.type : item.kind
978    if (key === 'done') return item.kind !== 'task' ? (p.total ? `${p.done}/${p.total}` : '') : list.length ? `${list.filter(c => c.done).length}/${list.length}` : ''
979    if (key === 'who') return item.assignee ? `@${item.assignee}` : ''
980    if (key === 'pri') return item.kind === 'task' ? item.priority : ''
981    return ''
982  }
983  const hasTriage = allRows.some(({ item }) => isTriage(item))
984  const TRIAGE_TAIL = hasTriage ? 1 + 6 + 1 + 12 : 0
985  const idWidth = 2 + Math.max(2, ...allRows.map(({ item }) => item.id.length))
986  const planColumns = fitColumns([
987    { key: 'id', label: 'ID', width: idWidth, drop: 0 },
988    { key: 'title', label: 'Title', width: 'fill', drop: 0, most: Math.max(5, ...allRows.map(({ item, depth }) => depth * 2 + item.title.length)) },
989    { key: 'news', label: '', width: 3, drop: 8 },
990    { key: 'ship', label: 'Release', width: 10, drop: 4 },
991    { key: 'due', label: 'Due', width: 10, drop: 6 },
992    { key: 'type', label: 'Type', width: 9, drop: 7 },
993    { key: 'done', label: 'Done', width: 5, drop: 3, align: 'right' },
994    { key: 'who', label: 'Assignee', width: Math.min(14, Math.max(8, ...allRows.map(({ item }) => (item.assignee ?? '').length + 1))), drop: 5 },
995    { key: 'pri', label: 'Pri', width: 3, drop: 2 },
996  ].filter(one => one.drop === 0 || allRows.some(({ item }) => planValue(item, one.key))) as TableColumn[], width - 2 - (hasTriage ? 2 : 0) - TRIAGE_TAIL)
997  const planHead = (
998    <Text key="plan-head" dimColor bold>
999      {'\u00a0\u00a0'}
1000      {hasTriage ? '\u00a0\u00a0' : ''}
1001      {planColumns.map(one => cellOf(one.label, one.width, one.align)).join(' ')}
1002    </Text>
1003  )
1004  const planRow = ({ item, depth }: { item: Item; depth: number }) => {
1005    const status = statusOf(items, item)
1006    const controls = isTriage(item)
1007    const color: Record<string, string | undefined> = { ship: waitingRelease.has(item.id) ? 'yellow' : 'green', who: 'cyan', news: 'magenta', pri: PRIORITY_COLOR[item.priority] }
1008    const row = (
1009      <Box key={`tree-${item.id}`} flexDirection="row" columnGap={1}>
1010        {foldToggle(item)}
1011        {hasTriage && (controls ? (
1012          <Button key={`pick-${item.id}`} plain
1013            onPress={() => act.setPicked(picked.includes(item.id) ? picked.filter(id => id !== item.id) : [...picked, item.id])}>
1014            <Text color={picked.includes(item.id) ? 'green' : undefined}>{picked.includes(item.id) ? '☑' : '☐'}</Text>
1015          </Button>
1016        ) : <Text key={`pick-${item.id}`}> </Text>)}
1017        <Button key={`row-${item.id}`} plain onPress={choose(item.id)}>
1018          {planColumns.map((one, i) => {
1019            const gap = i ? ' ' : ''
1020            if (one.key === 'id')
1021              return (
1022                <Text key={`c-${one.key}`}>
1023                  {isDropped(item) ? <Text dimColor>{WONTDO_GLYPH}</Text> : <Text color={COLOR[status]}>{GLYPH[status]}</Text>}{' '}
1024                  <Text dimColor>{cellOf(item.id, one.width - 2)}</Text>
1025                </Text>
1026              )
1027            if (one.key === 'title')
1028              return (
1029                <Text key={`c-${one.key}`} bold={item.kind === 'milestone'} dimColor={isDropped(item)} strikethrough={isDropped(item)}>
1030                  {gap}
1031                  {cellOf(`${'\u00a0\u00a0'.repeat(depth)}${item.title}`, one.width)}
1032                </Text>
1033              )
1034            return (
1035              <Text key={`c-${one.key}`} color={color[one.key]} dimColor={!color[one.key] || one.key === 'ship'} bold={one.key === 'news'}>
1036                {gap}
1037                {cellOf(planValue(item, one.key), one.width, one.align)}
1038              </Text>
1039            )
1040          })}
1041        </Button>
1042        {controls && (Select ? (
1043          <Select key={`prio-${item.id}`} options={PRIORITIES.map(one => ({ value: one }))} value={item.priority}
1044            onSelect={(value: string) => act.userAct({ action: 'update', id: item.id, priority: value })} />
1045        ) : (
1046          <Text key={`prio-${item.id}`} color={PRIORITY_COLOR[item.priority]}>{item.priority}</Text>
1047        ))}
1048        {controls && <Button key={`hand-${item.id}`} label="→ Claude" onPress={() => act.askHand(item.id)} />}
1049      </Box>
1050    )
1051    return handing === item.id ? (
1052      <Box key={`tree-wrap-${item.id}`} flexDirection="column">
1053        {row}
1054        {confirmHand(item)}
1055      </Box>
1056    ) : row
1057  }
1058
1059  const tree = (
1060    <Box flexDirection="column">
1061      {allRows.length === 0 && <Text dimColor>Nothing planned yet. Press n to add a milestone, an epic or a task.</Text>}
1062      {allRows.length > 0 && planHead}
1063      {treeShown.map((one, i) => {
1064        const isHead = allRows.indexOf(one) === firstUnplanned
1065        return isHead ? (
1066          <Box key={`unplanned-${one.item.id}`} flexDirection="column">
1067            <Box key="unplanned-head" flexDirection="row" columnGap={1} flexWrap="wrap">
1068              <Text bold dimColor>
1069                Unplanned{unheld ? `  ${unheld} for anyone to take` : ''}
1070              </Text>
1071              {/* Picked rows run at once: each its own agent, worktree and branch. */}
1072              {picked.length > 0 && !(parallelAsk && !pick) && (
1073                <Button key="run-picked" label={`Run ${picked.length} at once…`} variant="primary" onPress={() => act.askParallel(picked)} />
1074              )}
1075              {picked.length > 0 && !(parallelAsk && !pick) && <Button key="unpick" label="Clear picks" onPress={() => act.setPicked([])} />}
1076            </Box>
1077            {picked.length > 0 && parallelAsk && !pick && confirmParallel(parallelAsk, 'parallel-confirm')}
1078            {planRow(one)}
1079          </Box>
1080        ) : (
1081          planRow(one)
1082        )
1083      })}
1084      {treeShown.length < allRows.length && <Text key="tree-more" dimColor>…{allRows.length - treeShown.length} more rows (close the card to see them all)</Text>}
1085      {looseDone.length > 0 && !query && treeShown.length === allRows.length && (
1086        <Button key="fold-loose" plain onPress={() => act.toggleFold(LOOSE)}>
1087          <Text dimColor>
1088            {showsLoose ? '▾' : '▸'} {looseDone.length} finished task{looseDone.length === 1 ? '' : 's'} in no epic
1089          </Text>
1090        </Button>
1091      )}
1092    </Box>
1093  )
1094
1095  // The timeline: milestones and epics by due date, each with its progress and how it stands against the date.
1096  const today = now > 0 ? dateOf(now) : undefined
1097  const timelineAll = timelineRows(items, isFolded).filter(({ item }) => !query || subtree(items, item.id).some(id => isShown(find(items, id)!)))
1098  const timelineShown = timelineAll
1099  const BAR = 10
1100  // The timeline lines up in columns: the name, the date (a dim dash for none), the bar and its count,
1101  // then how it stands against its date.
1102  const countWidth = Math.max(0, ...timelineAll.map(({ item }) => { const p = progress(items, item); return `${p.done}/${p.total}`.length }))
1103  const right = 2 + 10 + 2 + BAR + 1 + countWidth
1104  // How each stands against its date: room is kept for it, up to a point, before the names take the rest.
1105  const standing = (one: Item) => {
1106    const p = progress(items, one)
1107    const days = one.due && today ? daysBetween(today, one.due) : undefined
1108    if (days === undefined || statusOf(items, one) === 'done') return ''
1109    if (isLate(items, one, now)) return `${-days} day${days === -1 ? '' : 's'} late, ${p.total - p.done} open`
1110    return days === 0 ? 'due today' : `in ${days} day${days === 1 ? '' : 's'}`
1111  }
1112  const whenWidth = Math.min(22, Math.max(0, ...timelineAll.map(({ item }) => standing(item).length)))
1113  const nameWidth = Math.min(
1114    Math.max(0, ...timelineAll.map(({ item, depth }) => depth * 2 + 2 + 2 + item.id.length + 1 + item.title.length)),
1115    Math.max(20, width - right - 2 - (whenWidth ? whenWidth + 2 : 0)),
1116  )
1117  // The roadmap on a time axis, in a pane wide enough for one: a label column, then each milestone as a
1118  // marker on its date and each epic as a bar from its start to its end, filled as far as its tasks are
1119  // done; a line for today; late work in red. Work without dates is listed under it. A narrow pane keeps
1120  // the list (timelineView) below.
1121  const isAxis = width >= 100
1122  const DAY = 86_400_000
1123  const ZOOMS = [0, 120, 45] as const
1124  const dayOf = (date: string) => Math.floor(Date.parse(`${date}T00:00:00Z`) / DAY)
1125  const todayDay = now > 0 ? Math.floor(now / DAY) : undefined
1126  // An epic without a due date still has a place: to when it finished (its last task's change), or, still
1127  // going, to today with an open end (▸). A milestone without one shows when any of its epics does.
1128  const todayDate = now > 0 ? dateOf(now) : undefined
1129  const spans = new Map(timelineAll.map(({ item }) => {
1130    const span = spanOf(snap, item)
1131    if (span.end || item.kind !== 'epic') return [item.id, { ...span, isOpenEnded: false }]
1132    const isDone = statusOf(items, item) === 'done'
1133    const finished = tasksIn(items, item).map(one => one.updated_at.slice(0, 10)).sort().at(-1)
1134    const end = isDone ? finished : todayDate
1135    return [item.id, { ...span, end: end && end < span.start ? span.start : end, isOpenEnded: !isDone && Boolean(end) }]
1136  }))
1137  const isPlaced = (item: Item) => spans.get(item.id)!.end !== undefined || (item.kind === 'milestone' && Boolean(item.due))
1138  const dated = timelineAll.filter(({ item }) => isPlaced(item) ||
1139    (item.kind === 'milestone' && timelineAll.some(({ item: one }) => upOf(one) === item.id && isPlaced(one))))
1140  const undated = timelineAll.filter(row => !dated.includes(row))
1141  const days = [
1142    ...dated.flatMap(({ item }) => { const one = spans.get(item.id)!; return [dayOf(one.start), ...(one.end ? [dayOf(one.end)] : [])] }),
1143    // The releases too, so each has its place on the axis.
1144    ...(dated.length ? shippedVersions.filter(one => one.at).map(one => dayOf(one.at)) : []),
1145  ]
1146  // With nothing dated and no clock, the axis has nothing to span: today's date stands in, unseen.
1147  const fitFrom = days.length || todayDay !== undefined ? Math.min(...days, todayDay ?? Infinity) : 0
1148  const fitTo = days.length || todayDay !== undefined ? Math.max(...days, todayDay ?? -Infinity) : 0
1149  const zoomDays = ZOOMS[zoom % ZOOMS.length]!
1150  // Zoomed in, the window centres on today; at fit it spans all the dated work (and today), a little padded.
1151  const [from, to] = zoomDays && todayDay !== undefined
1152    ? [todayDay - Math.floor(zoomDays / 3), todayDay + zoomDays - Math.floor(zoomDays / 3)]
1153    : [fitFrom - 2, Math.max(fitTo + 2, fitFrom + 14)]
1154  const labelWidth = Math.min(34, Math.max(18, Math.floor(width * 0.3)))
1155  const chart = Math.max(10, width - labelWidth - 1)
1156  const colOf = (day: number) => Math.round(((day - from) / Math.max(1, to - from)) * (chart - 1))
1157  const inChart = (col: number) => col >= 0 && col < chart
1158  // Ticks: weeks when they're far enough apart to label, else months.
1159  const isWeekly = chart / Math.max(1, (to - from) / 7) >= 7
1160  const ticks: { col: number; label: string }[] = []
1161  // At most ten years of days are walked, whatever dates were typed.
1162  for (let d = from; d <= Math.min(to, from + 3660); d++) {
1163    const date = new Date(d * DAY)
1164    const isTick = isWeekly ? date.getUTCDay() === 1 : date.getUTCDate() === 1
1165    if (isTick) ticks.push({ col: colOf(d), label: isWeekly ? date.toISOString().slice(5, 10) : date.toLocaleString('en', { month: 'short', timeZone: 'UTC' }) })
1166  }
1167  const scale = Array<string>(chart).fill(' ')
1168  let last = -2
1169  for (const tick of ticks) {
1170    if (tick.col <= last + 1 || tick.col + tick.label.length > chart) continue
1171    for (const [i, ch] of [...tick.label].entries()) scale[tick.col + i] = ch
1172    last = tick.col + tick.label.length
1173  }
1174  const todayCol = todayDay === undefined ? -1 : colOf(todayDay)
1175  type Cell = { ch: string; color?: string; isDim?: boolean }
1176  /** A row of the chart: the today line, then a milestone's marker or an epic's bar over it. */
1177  const chartCells = (item: Item): Cell[] => {
1178    const cells: Cell[] = Array.from({ length: chart }, (_, col) => (col === todayCol ? { ch: '│', color: 'yellow', isDim: true } : { ch: ' ' }))
1179    const span = spans.get(item.id)!
1180    const st = statusOf(items, item)
1181    const late = isLate(items, item, now)
1182    if (item.kind === 'milestone') {
1183      if (!item.due) return cells
1184      const col = colOf(dayOf(item.due))
1185      if (inChart(col)) cells[col] = { ch: '◆', color: late ? 'red' : st === 'done' ? 'green' : 'blue' }
1186      return cells
1187    }
1188    if (!span.end) return cells
1189    // A start after the end (work begun past its date) draws from the end: the bar is at least its last day.
1190    const a = Math.max(0, colOf(Math.min(dayOf(span.start), dayOf(span.end))))
1191    const b = Math.min(chart - 1, colOf(dayOf(span.end)))
1192    const p = progress(items, item)
1193    const filled = p.total ? Math.round(((b - a + 1) * p.done) / p.total) : 0
1194    for (let col = a; col <= b; col++)
1195      cells[col] = col - a < filled ? { ch: '█', color: late ? 'red' : 'green' } : { ch: '░', color: late ? 'red' : undefined, isDim: !late }
1196    // Still going with no date to end on: its bar runs to today and stays open.
1197    if (span.isOpenEnded && inChart(b + 1)) cells[b + 1] = { ch: '▸', isDim: true }
1198    return cells
1199  }
1200  /** Cells drawn as runs of one style each. */
hooks/paint.ts 194 lines
1/**
2 * A rough terminal paint of a drawn tree, for the layout tests: the kit hands back the tree a render
3 * hook returned, never the terminal's paint, so this lays it out much as Ink does (boxes in rows and
4 * columns, text wrapped at word boundaries) and reports where it can't fit: a row whose children are
5 * wider than it, a box whose content is taller than the height it was given, a line wider than the pane.
6 * It is a model, not Ink: close enough to catch a column that runs into a card, not a pixel test.
7 */
8
9type Node = { type: string; props?: Record<string, unknown>; children?: Child[] } | string | number | null | undefined | boolean
10type Child = Node
11
12export type Painted = { lines: string[]; problems: string[] }
13
14/** The cells a string takes: characters, as the terminal counts most of them. */
15export const cells = (text: string) => [...text].length
16
17/** `text` wrapped at word boundaries to `width`, a word longer than a line broken across lines. */
18export function wrap(text: string, width: number): string[] {
19  const out: string[] = []
20  for (const whole of text.split('\n')) {
21    // A line's indentation stays, as the terminal draws it.
22    const lead = /^ */.exec(whole)![0]
23    const para = whole.slice(lead.length)
24    let line = lead.length < width ? lead : ''
25    for (const word of para.split(' ')) {
26      if (line.trim() === '') line += word
27      else if (cells(line) + 1 + cells(word) <= width) line += ` ${word}`
28      else {
29        out.push(line)
30        line = word
31      }
32      while (width > 0 && cells(line) > width) {
33        out.push([...line].slice(0, width).join(''))
34        line = [...line].slice(width).join('')
35      }
36    }
37    out.push(line)
38  }
39  return out
40}
41
42const num = (value: unknown) => (typeof value === 'number' ? value : 0)
43/** A node's children, flattened: as the drawn tree has them, or as an element under construction holds them. */
44export const kids = (node: Node): Child[] => {
45  if (!node || typeof node !== 'object') return []
46  const raw = (node.children ?? (node.props as { children?: unknown } | undefined)?.children ?? []) as unknown
47  const flat = (one: unknown): Child[] => (Array.isArray(one) ? one.flatMap(flat) : [one as Child])
48  return flat(raw)
49}
50const isInline = (node: Node) =>
51  node === null || node === undefined || typeof node !== 'object' || node.type === 'Text' || node.type === 'Link' ||
52  (node.type === 'Button' && Boolean(node.props?.plain) && kids(node).length > 0)
53
54/** What an inline node says, flattened. */
55function textOf(node: Node): string {
56  if (node === null || node === undefined || typeof node === 'boolean') return ''
57  if (typeof node !== 'object') return String(node)
58  // A plain Button with a hotkey is drawn "k: label".
59  const key = node.type === 'Button' && node.props?.plain && node.props?.hotkey ? `${String(node.props.hotkey)}: ` : ''
60  if (node.type === 'Button' && !(node.props?.plain && kids(node).length)) {
61    const label = String(node.props?.label ?? '')
62    return node.props?.plain ? key + label : `[ ${label} ]`
63  }
64  if (key) return key + kids(node).map(textOf).join('')
65  if (node.type === 'Input') return String(node.props?.value || node.props?.placeholder || '').padEnd(10)
66  if (node.type === 'Select') {
67    const options = (node.props?.options ?? []) as { label?: string; value?: string }[]
68    const chosen = options.find(one => one.value === node.props?.value) ?? options[0]
69    return `${chosen?.label ?? ''} ▾`
70  }
71  return kids(node).map(textOf).join('')
72}
73
74/** The width a node takes when nothing limits it. */
75function natural(node: Node): number {
76  if (node && typeof node === 'object' && node.props?.display === 'none') return 0
77  if (isInline(node) || (typeof node === 'object' && node && node.type !== 'Box')) return Math.max(0, ...textOf(node).split('\n').map(cells))
78  const props = (node as { props?: Record<string, unknown> }).props ?? {}
79  if (typeof props.width === 'number') return props.width
80  const edge = edges(props)
81  const children = kids(node).filter(child => textOf(child) !== '' || (typeof child === 'object' && child?.type === 'Box'))
82  const gap = num(props.columnGap ?? props.gap)
83  const inner = props.flexDirection === 'row'
84    ? children.reduce((sum, child) => sum + natural(child), 0) + gap * Math.max(0, children.length - 1)
85    : Math.max(0, ...children.map(natural))
86  return inner + edge.x
87}
88
89function edges(props: Record<string, unknown>) {
90  const border = props.borderStyle ? 1 : 0
91  const left = num(props.paddingLeft ?? props.paddingX ?? props.padding) + border + num(props.marginLeft ?? props.marginX ?? props.margin)
92  const right = num(props.paddingRight ?? props.paddingX ?? props.padding) + border + num(props.marginRight ?? props.marginX ?? props.margin)
93  const top = num(props.paddingTop ?? props.paddingY ?? props.padding) + border + num(props.marginTop ?? props.marginY ?? props.margin)
94  const bottom = num(props.paddingBottom ?? props.paddingY ?? props.padding) + border + num(props.marginBottom ?? props.marginY ?? props.margin)
95  return { left, right, top, bottom, x: left + right }
96}
97
98const keyOf = (node: Node) => (node && typeof node === 'object' ? String(node.props?.key ?? node.type) : String(node))
99
100/** Paints `node` into `width` columns. */
101export function paint(node: Node, width: number, problems: string[] = [], path = 'pane'): string[] {
102  if (node === null || node === undefined || typeof node === 'boolean') return []
103  // A Box drawn `display: "none"` takes no room (a hover may show it, over the rest).
104  if (typeof node === 'object' && node.props?.display === 'none') return []
105  if (isInline(node) || (typeof node === 'object' && node.type !== 'Box')) {
106    const text = textOf(node)
107    if (text === '') return []
108    const style = typeof node === 'object' ? node.props?.wrap : undefined
109    if (typeof style === 'string' && style.startsWith('truncate')) return text.split('\n').map(line => [...line].slice(0, width).join(''))
110    return wrap(text, width)
111  }
112  const props = node.props ?? {}
113  const here = `${path} > ${keyOf(node)}`
114  const outer = typeof props.width === 'number' ? props.width : width
115  if (outer > width) problems.push(`${here} is ${outer} wide in ${width}`)
116  const edge = edges(props)
117  const inner = Math.max(1, outer - edge.x)
118  const children = kids(node).filter(child => child !== '' && child !== null && child !== undefined && typeof child !== 'boolean')
119  let body: string[] = []
120  if (props.flexDirection === 'row') {
121    const gap = num(props.columnGap ?? props.gap)
122    const wants = children.map(child => Math.min(natural(child), typeof child === 'object' && child?.props && typeof child.props.width === 'number' ? child.props.width : Infinity))
123    const fixed = children.map(child => typeof child === 'object' && child?.props && typeof child.props.width === 'number')
124    if (props.flexWrap === 'wrap') {
125      // Children flow on to the next line when the row is full.
126      let line: { child: Child; width: number }[] = []
127      let used = 0
128      const flush = () => {
129        if (line.length) body.push(...sideBySide(line.map(one => ({ lines: paint(one.child, one.width, problems, here), width: one.width })), gap))
130        line = []
131        used = 0
132      }
133      children.forEach((child, i) => {
134        const want = Math.min(wants[i]!, inner)
135        if (line.length && used + gap + want > inner) flush()
136        used += (line.length ? gap : 0) + want
137        line.push({ child, width: want })
138      })
139      flush()
140    } else {
141      const total = wants.reduce((sum, one) => sum + one, 0) + gap * Math.max(0, children.length - 1)
142      const fixedTotal = wants.reduce((sum, one, i) => sum + (fixed[i] ? one : 0), 0) + gap * Math.max(0, children.length - 1)
143      if (fixedTotal > inner) problems.push(`${here}: children of fixed width take ${fixedTotal} of ${inner}`)
144      // Over the row's width, what isn't fixed shrinks in proportion; under it, grow takes the rest.
145      let widths = wants
146      if (total > inner) {
147        const flexible = wants.reduce((sum, one, i) => sum + (fixed[i] ? 0 : one), 0)
148        const room = Math.max(0, inner - fixedTotal)
149        widths = wants.map((one, i) => (fixed[i] || flexible === 0 ? one : Math.max(1, Math.floor((one * room) / flexible))))
150      } else {
151        const grow = children.map(child => num(typeof child === 'object' && child?.props ? child.props.flexGrow : 0))
152        const share = grow.reduce((sum, one) => sum + one, 0)
153        if (share > 0) widths = wants.map((one, i) => one + Math.floor(((inner - total) * grow[i]!) / share))
154      }
155      body = sideBySide(children.map((child, i) => ({ lines: paint(child, widths[i]!, problems, here), width: widths[i]! })), gap)
156    }
157  } else {
158    const gap = num(props.rowGap ?? props.gap)
159    children.forEach((child, i) => {
160      if (i > 0) for (let n = 0; n < gap; n++) body.push('')
161      body.push(...paint(child, inner, problems, here))
162    })
163  }
164  if (typeof props.height === 'number') {
165    const room = props.height - edge.top - edge.bottom
166    if (body.length > room) problems.push(`${here} holds ${body.length} rows in a height of ${room}`)
167    while (body.length < room) body.push('')
168  }
169  const pad = ' '.repeat(edge.left)
170  const lines = [
171    ...Array<string>(edge.top).fill(''),
172    ...body.map(line => (edge.left ? pad + line : line)),
173    ...Array<string>(edge.bottom).fill(''),
174  ]
175  for (const line of lines) if (cells(line) > outer) problems.push(`${here}: a line of ${cells(line)} in ${outer}: "${line.trim().slice(0, 60)}"`)
176  return lines
177}
178
179function sideBySide(blocks: { lines: string[]; width: number }[], gap: number): string[] {
180  const rows = Math.max(0, ...blocks.map(block => block.lines.length))
181  const out: string[] = []
182  for (let r = 0; r < rows; r++)
183    out.push(blocks.map((block, i) => (block.lines[r] ?? '').padEnd(i < blocks.length - 1 ? block.width : 0)).join(' '.repeat(gap)).trimEnd())
184  return out
185}
186
187/** Paints a drawn pane, with what didn't fit. */
188export function paintPane(tree: unknown, width: number): Painted {
189  const problems: string[] = []
190  const lines = paint(tree as Node, width, problems)
191  for (const [i, line] of lines.entries()) if (cells(line) > width) problems.push(`line ${i + 1} is ${cells(line)} wide in ${width}`)
192  return { lines, problems: [...new Set(problems)] }
193}
194
types/index.d.ts 160 lines
1export type Kind = 'milestone' | 'epic' | 'task'
2export type Status = 'todo' | 'in_progress' | 'blocked' | 'review' | 'done'
3/** How urgent: p0 drops everything, p2 is the default, p3 can wait. */
4export type Priority = 'p0' | 'p1' | 'p2' | 'p3'
5/** What sort of work an item is, as Jira's issue type. */
6export type IssueType = 'feature' | 'bug' | 'chore'
7/** A CHANGELOG section a task's release note goes under. */
8export type Section = 'Added' | 'Changed' | 'Fixed'
9/** A link other than blocked-by: this item relates to, or duplicates, item `id`. */
10export type Relation = { type: 'relates' | 'duplicates'; id: string }
11
12export type Item = {
13  id: string
14  kind: Kind
15  title: string
16  status: Status
17  /** The epic a task belongs to; null for an epic, a milestone, or a task in no epic. */
18  parent: string | null
19  /** The milestone an epic or task targets; a task without one takes its epic's. */
20  milestone: string | null
21  description: string | null
22  assignee: string | null
23  /** When a milestone or epic is meant to start (YYYY-MM-DD); without one, the roadmap derives it. */
24  start: string | null
25  due: string | null
26  priority: Priority
27  type: IssueType
28  /** The task's line for the CHANGELOG, as the person using the project reads it; `-` for none needed. */
29  note: string | null
30  /** The CHANGELOG section the note goes under. */
31  section: Section | null
32  /** How a closed task was closed, when not by doing it: `wontdo`, dropped (with its reason on the timeline). */
33  resolution: 'wontdo' | null
34  /** When the holder last showed signs of life; a claim gone quiet too long can be taken over. */
35  lease_at: string | null
36  /** Free-form tags, sorted. */
37  labels: string[]
38  /** Links this item makes to others (stored on this side). */
39  relations: Relation[]
40  /** Ids of the tasks this one waits on (`links`); empty for none. */
41  blocked_by: string[]
42  /** Acceptance criteria, in order; a task with any unchecked is not done. */
43  checklist: Check[]
44  created_at: string
45  updated_at: string
46}
47
48/**
49 * What to look for (`find`, and the board's filter); every field given must match. `assignee` "none"
50 * means unassigned; `labels` matches an item carrying any of them; `text` searches title, description
51 * and what was written on the item.
52 */
53export type Query = {
54  kind?: Kind
55  status?: Status[]
56  assignee?: string[]
57  priority?: Priority[]
58  type?: IssueType[]
59  labels?: string[]
60  under?: string
61  /** A milestone id: the epics and tasks that target it (a task its own, else its epic's), and it. */
62  milestone?: string
63  text?: string
64}
65
66/** One item of a `plan` call: a new item and, nested under it, its own new items. */
67export type PlanNode = {
68  /** A name other nodes' blocked_by can use before the item has an id; defaults to its place, `#1`, `#2`… */
69  ref?: string
70  kind: Kind
71  title: string
72  description?: string
73  /** A milestone's or epic's start date, YYYY-MM-DD. */
74  start?: string
75  due?: string
76  assignee?: string
77  priority?: Priority
78  type?: IssueType
79  /** The milestone an epic or task targets (an id, or a ref of a new milestone in the same plan). */
80  milestone?: string
81  labels?: string[]
82  checklist?: string[]
83  /** Refs of other new tasks, or ids of existing ones. */
84  blocked_by?: string[]
85  children?: PlanNode[]
86}
87
88/** A plan node checked and placed: under an existing item (`parentId`) or a new one (`parentRef`). */
89export type PlannedItem = {
90  ref: string
91  node: PlanNode
92  parentId: string | null
93  parentRef: string | null
94  blockerRefs: string[]
95  blockerIds: string[]
96}
97
98/** One acceptance criterion: `n` is its 1-based place in the list. */
99export type Check = { n: number; text: string; done: boolean }
100
101/** One entry of an item's timeline: a comment, or a change someone made. */
102export type Activity = {
103  id: number
104  item_id: string
105  author: string
106  /**
107   * `handoff`: the note an agent leaves when it lets a task go, for whoever picks it up. `remove`: an
108   * item removed, logged under its id; `undo`: a change taken back (or, undone itself, made again).
109   */
110  type: 'create' | 'status' | 'assign' | 'edit' | 'comment' | 'handoff' | 'remove' | 'undo'
111  body: string
112  at: string
113  /** The write it was logged in: entries of one op were one change, and are undone together. */
114  op?: number | null
115  /** The undo entry that took it back, while it stays taken back. */
116  undone?: number | null
117  /** Whether it can be taken back. */
118  undoable?: boolean
119}
120
121/** The roadmap as read: items, recent activity, and the newest activity id the user has seen per item. */
122export type Snapshot = { items: Item[]; activity: Activity[]; seen: Record<string, number>; releases?: Release[]; inbox?: InboxItem[] }
123
124/**
125 * Something filed to sort later (an idea, a bug, a "we should…"), kept apart from planned work: open
126 * until triaged into a task or epic, or folded into existing work (`became` names it), or dropped (`reason`).
127 */
128export type InboxItem = { id: string; title: string; body: string | null; author: string; at: string; state: 'open' | 'triaged' | 'dropped'; became: string | null; reason: string | null }
129
130/** A version that shipped: when, from which tag and release PR, its notes, and the tasks it carried. */
131export type Release = { version: string; tag: string | null; at: string; pr: number | null; notes: string; tasks: { id: string; note: string; section: Section | null }[] }
132
133export type View = 'inbox' | 'plan' | 'roadmap' | 'board' | 'releases'
134
135/** The new-item form's choices so far; the title is typed last and submits it. */
136export type Draft = {
137  kind: Kind; priority: Priority; type: IssueType; parent: string
138  /** The inbox item it is made from, when triaging one; its title starts as the item's. */
139  from?: string; title?: string
140}
141
142/** A commit whose message names roadmap ids. */
143export type Commit = { hash: string; author: string; date: string; subject: string; ids: string[] }
144
145/** A pull request whose title or branch names roadmap ids. */
146/** A pull request whose title or branch names roadmap ids; `checks` sums up its CI. */
147export type Pr = { number: number; title: string; state: string; url: string; ids: string[]; checks: Checks; /** Its head branch. */ branch: string; /** The branch it merges into. */ base: string }
148
149/** A pull request's checks at a glance: none reported, still running, all passed, or one failed. */
150export type Checks = 'none' | 'pending' | 'pass' | 'fail'
151
152/** What the repository says about the roadmap: commits and pull requests that name items. */
153export type Refs = { commits: Commit[]; prs: Pr[]; /** The version the stable branch serves, when known. */ stable?: string }
154
155declare module 'claude-code' {
156  interface PluginState {
157    roadmap: { snapshot: Snapshot; view: View; selected: string | null; problem: string | null; refs: Refs; scrolled: number; ignoreOffer: boolean; requesting: boolean; filter: string; filtering: boolean; draft: Draft | null; editing: boolean; handing: string | null; merging: string | null; noting: string | null; commentTurns: boolean; stacking: string | null; stackRun: string; picked: string[]; parallelAsk: string[] | null; doneOpen: boolean; flipped: string[]; dropping: string | null; filing: boolean; releasing: boolean; zoom: number; triaging: { id: string; mode: 'into' | 'drop' } | null; viewScrolled: Record<string, number>; region: 'list' | 'card'; split: number | null; revealing: boolean }
158  }
159}
160