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

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
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.
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.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).ship records: the version, and the tasks it carried.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.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.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.● N badge marks cards with comments you haven't read yet; Mark all read, by the unread count in the header, clears them all.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, 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.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.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.#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..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.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:
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).⌛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.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.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.find: by status, assignee (none for unassigned), priority, type, labels, a subtree (under) or words in titles, descriptions and comments.next to pick up the next task that's ready to start, highest priority first.file), and sorts the inbox when you ask (triage): it proposes what each item becomes, and acts once you agree.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).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.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.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.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.
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.
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).
See CHANGELOG.md.
MIT. See LICENSE.
hooks/register.tsx 1899 lines1import { 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}
1200hooks/model.ts 1174 lines1import 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}
1174hooks/db.ts 656 lines1import 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}
656hooks/pane.tsx 2083 lines1import 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 lines1/**
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}
194types/index.d.ts 160 lines1export 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