A live wave board of your session's plan above the prompt: waves, tasks and their state, kept current by Claude through three tools and switched with /taskrail.

<h1 align="center">taskrail</h1>
<a href="https://github.com/drolosoft/taskrail/releases/latest"><img src="https://img.shields.io/github/v/release/drolosoft/taskrail?label=release" alt="GitHub Release"></a> <a href="https://opensource.org/licenses/MIT"><img src="https://img.shields.io/badge/License-MIT-yellow.svg" alt="License: MIT"></a> <img src="https://img.shields.io/badge/Claude%20Code-2.1.287%2B-lightgrey.svg" alt="Claude Code 2.1.287+">
🛤️ A live board of your session's plan, right above the prompt.
taskrail is a Claude Code mod. When Claude works through a plan with several tasks, the board shows the waves, the tasks under each one and the state each task is in, updated by Claude itself as the work moves. You pick how much of it you see with /taskrail.
claude plugin marketplace add drolosoft/claude-plugins
claude plugin install taskrail@drolosoft-marketplace
Start a new session, or run /reload-plugins. /plugin then lists it as 1 mod active · taskrail.
For one session with a local copy: claude --plugin-dir ./taskrail.
/taskrail … | What the band shows |
|---|---|
full | The whole board: header, description, rail, tasks, key line and progress bar (the default) |
bar | One line: project, plan, time, a station per wave, the goal and ten tiles |
both | The whole board, and Claude also pastes it in the chat at every milestone |
off | Nothing in the band; Claude shows the board in the chat only when you ask |
/taskrail alone answers the current mode. The choice is kept across sessions.
If you already have a skill or a command named taskrail, Claude Code keeps yours and refuses the mod's. The board and the three tools still work, in the mode last chosen; rename yours to get /taskrail back.
The mod registers three tools. Claude calls them on its own when it runs a plan with several tasks; you can also ask for them by name.
| Tool | What it does |
|---|---|
plan | Starts (or replaces) the board: project, title, goal, description and the waves with their task ids. Every task starts 🥚. |
set | Updates task icons by id, the key line, the goal, the title or the description. Its result tells Claude what the current mode asks of the chat. |
show | Returns the board as text, for the chat. |
Icons: 🥚 pending · 🔧 working · 👀 in review · 🩹 fixing · 🧪 testing · 🟩 merged · 🚀 shipped · 🔑 needs you · 👻 missing · 🧟 stale · 💥 broken · 🥱 idle · 🛑 stopped. The header counts each of these icons in use. A wave's station is 🟩 when every task is merged; otherwise it shows the most pressing state among its tasks, in this order: 🛑 🔑 💥 🧟 👻 🩹 👀 🧪 🔧 🚀, else 🥚. The "N of M" counts 🟩 only; the tiles are green for 🟩, yellow for the running states (🔧 👀 🩹 🧪, at least one tile while anything runs) and red for the rest. plan and set also take a note, the key line under the rail.
The plan lives in the session's plugin state and in the plugin store under the session id, so it survives /resume, including a restart of Claude Code followed by --resume. /clear drops the plan along with the conversation, and a new session starts with no plan.
Nothing outside Claude Code. taskrail opens no network connection and reads or writes no file. It keeps the plan in the plugin's session state and in the plugin store, and draws it in the band above the prompt.
claude -p, or in cloud sessions.claude plugin validate --strict .
claude plugin test .
claude --plugin-dir .
Saving a file reloads the mod in a session started with --plugin-dir. Do not load the same mod from --plugin-dir and from the marketplace in one session: two plugins named taskrail would register the same tools twice.
See CONTRIBUTING.md. Security reports go by email, see SECURITY.md.
MIT. Copyright (c) 2026 Drolosoft.
hooks/register.tsx 263 lines1// The wave board as a mod: the plan of this session lives in `$.state`
2// (reactive, survives a hot reload) with a copy in `$.store` per session id
3// (survives /resume and a restart; /clear drops it). The band above the
4// prompt draws it; `/taskrail` picks the mode; the model updates it through
5// three tools.
6import { atom, read, update } from 'claude-code'
7import type { EngineInterface, Register } from 'claude-code'
8
9import type { WaveBoardMode, WaveBoardPlan } from '../types'
10import {
11 applyUpdates,
12 headerLine,
13 isMode,
14 MODES,
15 modeHint,
16 newPlan,
17 renderBar,
18 renderFull,
19 renderText,
20} from './board'
21
22// The plan of the session, null until the model creates one.
23const plan = atom({ plugin: 'taskrail', key: 'plan' } as const, null)
24
25// What the band shows; the whole board by default.
26const mode = atom({ plugin: 'taskrail', key: 'mode' } as const, 'full')
27
28// The store key of the mode, shared by every session on this machine.
29const MODE_KEY = 'mode'
30
31/** The store key of this session's plan. */
32async function planKey($: EngineInterface): Promise<string> {
33 return `plan:${await $.session.id()}`
34}
35
36/**
37 * Writes the plan to the state (redraws the band) and to the store (so it
38 * outlives /resume and a restart).
39 */
40async function savePlan($: EngineInterface, next: WaveBoardPlan): Promise<void> {
41 await update($, plan, () => next)
42 await $.store.set(await planKey($), next)
43}
44
45/** Loads the session's plan from the store, if any. */
46async function loadPlan($: EngineInterface): Promise<void> {
47 const stored = await $.store.get(await planKey($))
48
49 if (stored !== undefined) {
50 await update($, plan, () => stored as WaveBoardPlan)
51 }
52}
53
54/** Loads the mode the user last chose, kept in the store across sessions. */
55async function loadMode($: EngineInterface): Promise<void> {
56 const stored = await $.store.get(MODE_KEY)
57
58 if (isMode(stored)) {
59 await update($, mode, () => stored)
60 }
61}
62
63/** The input schema of the `plan` tool. */
64const PLAN_SCHEMA = {
65 type: 'object',
66 properties: {
67 project: { type: 'string', description: 'Short project name, shown first in the bar ("shop", "api").' },
68 title: { type: 'string', description: 'The plan as the header names it: "plan 7 · cleanup".' },
69 goal: { type: 'string', description: 'What the rail ends in, after 🚀: "v0.1.0", "cleanup → main".' },
70 description: { type: 'string', description: 'What the whole plan does, two lines at most; written once.' },
71 note: { type: 'string', description: 'The key line: what is not 🟩 and what is being waited on.' },
72 waves: {
73 type: 'array',
74 description: 'The waves in order; tasks with disjoint files and no consumer relation share a wave.',
75 items: {
76 type: 'object',
77 properties: {
78 name: { type: 'string', description: 'The station letter: A, B, C.' },
79 tasks: { type: 'array', items: { type: 'string' }, description: 'Task ids, short: T1, T2, rev, CI.' },
80 },
81 required: ['name', 'tasks'],
82 },
83 },
84 },
85 required: ['project', 'title', 'goal', 'waves'],
86}
87
88/** The input schema of the `set` tool. */
89const SET_SCHEMA = {
90 type: 'object',
91 properties: {
92 tasks: {
93 type: 'object',
94 description: 'Task id to new icon: {"T4": "🟩", "T5": "👀"}. Icons: 🥚 🔧 👀 🩹 🧪 🟩 🚀 🔑 👻 🧟 💥 🥱 🛑.',
95 additionalProperties: { type: 'string' },
96 },
97 note: { type: 'string', description: 'Replaces the key line.' },
98 goal: { type: 'string' },
99 title: { type: 'string' },
100 description: { type: 'string' },
101 },
102}
103
104/**
105 * Registers `/taskrail`. The engine refuses the name when the user already
106 * has a skill or a command called `taskrail`, and the refusal throws; the
107 * mod then runs without its command and the mode stays as it was.
108 */
109async function registerCommand($: EngineInterface): Promise<void> {
110 try {
111 await $.command.register({
112 name: 'taskrail',
113 description: 'Wave board above the prompt: off, bar, full or both',
114 argumentHint: '[off|bar|full|both]',
115 immediate: true,
116 })
117 } catch {
118 // The user's own /taskrail stays; the tools still carry the board.
119 }
120}
121
122export const register: Register = on => {
123 on('session.start', async ($, e, next) => {
124 // A refused command must not take the rest of the hook down with it:
125 // without the tools, the mode and the plan there is no board at all,
126 // while without the command the board only keeps the mode it had.
127 await registerCommand($)
128
129 await $.tool.register({
130 name: 'plan',
131 description:
132 'Starts (or replaces) the wave board of this session: the plan\'s project, title, goal, description and its waves of task ids. Every task starts 🥚. The board is drawn above the prompt by the mod; call `set` on every state change.',
133 inputSchema: PLAN_SCHEMA,
134 })
135 await $.tool.register({
136 name: 'set',
137 description:
138 'Updates the wave board of this session: task icons by id, the key line (note), goal, title or description. Call it on EVERY state change (an implementer reports, a review lands, a merge, CI), never by editing files. Its result says what the current mode asks of the chat.',
139 inputSchema: SET_SCHEMA,
140 })
141 await $.tool.register({
142 name: 'show',
143 description:
144 'Returns the whole board as text, to paste in the chat inside a fenced code block when the user asks for it (mode off) or at every milestone (mode both). Never in modes bar or full.',
145 })
146
147 await loadMode($)
148 await loadPlan($)
149
150 return next(e)
151 })
152
153 // /clear, /resume and /branch reset every `$.state` value and fire no
154 // `session.start`; the classic event does, with the source that says so.
155 on('classic.SessionStart', { source: ['resume', 'fork'] }, async ($, e, next) => {
156 await loadMode($)
157 await loadPlan($)
158
159 return next(e)
160 })
161
162 // /clear starts over, and the plan goes with the conversation it belonged
163 // to; only the mode comes back, because it is a preference of the machine.
164 on('classic.SessionStart', { source: 'clear' }, async ($, e, next) => {
165 await loadMode($)
166
167 return next(e)
168 })
169
170 on('command.run', { command: 'taskrail' }, async ($, e) => {
171 const wanted = e.args.trim()
172 const current = await read($, mode)
173
174 if (wanted === '') {
175 const hasPlan = (await read($, plan)) !== null
176 return { text: `board: mode ${current}${hasPlan ? '' : ' (no plan in this session)'}` }
177 }
178
179 if (!isMode(wanted)) {
180 return { text: `usage: /taskrail [${MODES.join('|')}]` }
181 }
182
183 await update($, mode, () => wanted)
184 await $.store.set(MODE_KEY, wanted)
185
186 return { text: `board: mode ${wanted}` }
187 })
188
189 on('tool.call', { tool: 'mcp__taskrail__plan' }, async ($, e) => {
190 if (!Array.isArray(e.waves) || e.waves.length === 0) {
191 return { deny: 'taskrail: a plan needs at least one wave with its task ids.' }
192 }
193
194 const created = newPlan(e, await $.clock.now())
195 await savePlan($, created)
196
197 return { result: `${headerLine(created)}\n${modeHint(await read($, mode))}` }
198 })
199
200 on('tool.call', { tool: 'mcp__taskrail__set' }, async ($, e) => {
201 const current = await read($, plan)
202
203 if (current === null) {
204 return { deny: 'taskrail: no plan in this session yet; create one with mcp__taskrail__plan.' }
205 }
206
207 const { plan: changed, unknown } = applyUpdates(current, e, await $.clock.now())
208 await savePlan($, changed)
209
210 const warning = unknown.length > 0 ? `\nunknown task ids, ignored: ${unknown.join(', ')}` : ''
211
212 return { result: `${headerLine(changed)}${warning}\n${modeHint(await read($, mode))}` }
213 })
214
215 on('tool.call', { tool: 'mcp__taskrail__show' }, async $ => {
216 const current = await read($, plan)
217
218 if (current === null) {
219 return { deny: 'taskrail: no plan in this session yet; create one with mcp__taskrail__plan.' }
220 }
221
222 return { result: renderText(current) }
223 })
224
225 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
226 const current = await read($, plan)
227 const shown: WaveBoardMode = await read($, mode)
228
229 if (e.props.hasSurvey || current === null || shown === 'off') {
230 return next(e)
231 }
232
233 const { Box, Text } = $.ui.resolve(e)
234
235 if (shown === 'bar') {
236 return (
237 <Box>
238 <Text wrap="truncate-end">{renderBar(current)}</Text>
239 </Box>
240 )
241 }
242
243 // The rules stretch to the band's width, as the status line script
244 // stretched them to the terminal's; two cells short so nothing wraps.
245 const width = Math.max(40, e.props.bodyColumns - 2)
246 const lines = renderFull(current, width)
247
248 return (
249 <Box flexDirection="column">
250 {lines.map(segments => (
251 <Box flexDirection="row">
252 {segments.map(segment => (
253 <Text bold={segment.bold === true} wrap="truncate-end">
254 {segment.text}
255 </Text>
256 ))}
257 </Box>
258 ))}
259 </Box>
260 )
261 })
262}
263hooks/board.ts 384 lines1// The board itself: pure functions from a plan to the lines the band draws,
2// kept free of `$` so the hooks module can import it and `claude plugin
3// validate` still sees every call.
4import type { PlanInput, SetInput, WaveBoardMode, WaveBoardPlan, WaveBoardTask, WaveBoardWave } from '../types'
5
6/** A run of text in a row, bold when the task is in flight. */
7export type Segment = { text: string; bold?: boolean }
8
9/** The four modes `/taskrail` accepts, in the order the usage line lists them. */
10export const MODES: readonly WaveBoardMode[] = ['off', 'bar', 'full', 'both']
11
12// Every emoji the board uses is two terminal cells wide; everything else
13// (box drawing, Latin, digits) is one. Text glyphs never carry state.
14const EMOJI = new Set([...'🥚🔧👀🩹🧪🟩🚀🔑👻🧟💥🥱🛑◾🟨🟥'])
15
16// The order in which the header counts the icons: every icon the model
17// may set, finished work first and pending last.
18const ORDER = ['🟩', '🔧', '👀', '🩹', '🧪', '🚀', '🔑', '💥', '🛑', '👻', '🧟', '🥱', '🥚']
19
20// A wave's station shows its most urgent state: a stop first, then the
21// events, the running stages and a shipped task, in this order.
22const URGENCY = ['🛑', '🔑', '💥', '🧟', '👻', '🩹', '👀', '🧪', '🔧', '🚀']
23
24// The stages that count as "running" for the yellow tiles of the bar.
25const RUNNING = new Set(['🔧', '👀', '🩹', '🧪'])
26
27// Width of the rules when the band cannot tell its own width.
28export const DEFAULT_WIDTH = 66
29
30/**
31 * Terminal cells a string occupies.
32 * @param text plain text, no ANSI codes
33 */
34export function cells(text: string): number {
35 let count = 0
36
37 for (const char of text) {
38 count += EMOJI.has(char) ? 2 : 1
39 }
40
41 return count
42}
43
44/**
45 * A cell padded with spaces up to `width` terminal cells.
46 * @param cell the cell's text
47 * @param width the column width in cells, ten by default
48 */
49export function pad(cell: string, width = 10): string {
50 return cell + ' '.repeat(Math.max(0, width - cells(cell)))
51}
52
53/**
54 * A wave's station icon: green when every task is merged, else the most
55 * urgent event or stage among its tasks, else pending.
56 */
57export function aggregate(icons: readonly string[]): string {
58 if (icons.length > 0 && icons.every(icon => icon === '🟩')) {
59 return '🟩'
60 }
61
62 for (const candidate of URGENCY) {
63 if (icons.includes(candidate)) {
64 return candidate
65 }
66 }
67
68 return '🥚'
69}
70
71/** Every task icon of the plan, wave by wave. */
72function allIcons(plan: WaveBoardPlan): string[] {
73 return plan.waves.flatMap(wave => wave.tasks.map(task => task[1]))
74}
75
76/** Icons of one wave. */
77function waveIcons(wave: WaveBoardWave): string[] {
78 return wave.tasks.map(task => task[1])
79}
80
81/** Epoch milliseconds as `HH:MM` in the machine's own time zone. */
82export function clock(ms: number): string {
83 const date = new Date(ms)
84 const hours = String(date.getHours()).padStart(2, '0')
85 const minutes = String(date.getMinutes()).padStart(2, '0')
86
87 return `${hours}:${minutes}`
88}
89
90/**
91 * Colour tiles at scale: one tile per `100 / scale` percent of the plan,
92 * green merged, yellow running, red pending or blocked. Never one tile per
93 * task.
94 */
95export function tiles(done: number, running: number, total: number, scale: number): string {
96 const green = total > 0 ? Math.round((done / total) * scale) : 0
97 // At least one yellow tile while anything runs, so the eye sees movement
98 // even when the share rounds to zero.
99 const yellow = running > 0 ? Math.max(1, Math.round((running / total) * scale)) : 0
100 const red = Math.max(0, scale - green - yellow)
101
102 return '🟩'.repeat(green) + '🟨'.repeat(yellow) + '🟥'.repeat(red)
103}
104
105/** The counts the header shows: `🟩 13 · 👀 1 · 🥚 2`, icons in ORDER. */
106export function counts(plan: WaveBoardPlan): string {
107 const icons = allIcons(plan)
108
109 return ORDER.filter(icon => icons.includes(icon))
110 .map(icon => `${icon} ${icons.filter(one => one === icon).length}`)
111 .join(' · ')
112}
113
114/** Merged tasks, running tasks and the total, the three numbers every view needs. */
115export function progress(plan: WaveBoardPlan): { done: number; running: number; total: number } {
116 const icons = allIcons(plan)
117 const done = icons.filter(icon => icon === '🟩').length
118 const running = icons.filter(icon => RUNNING.has(icon)).length
119
120 return { done, running, total: icons.length }
121}
122
123/**
124 * The one-line summary a tool result prints and the header carries:
125 * ` title · done of total · HH:MM · counts`.
126 */
127export function headerLine(plan: WaveBoardPlan): string {
128 const { done, total } = progress(plan)
129
130 return ` ${plan.title} · ${done} of ${total} · ${clock(plan.updatedAt)} · ${counts(plan)}`
131}
132
133/**
134 * The rail: ` main ` then one ten-cell station per wave and the goal. The
135 * rail glyph is `━` up to the last all-green station and `┄` after it.
136 */
137export function rail(plan: WaveBoardPlan): string {
138 const greens = plan.waves
139 .map((wave, index) => (aggregate(waveIcons(wave)) === '🟩' ? index : -1))
140 .filter(index => index >= 0)
141 const lastGreen = greens.length > 0 ? Math.max(...greens) : -1
142 let line = ' main '
143
144 plan.waves.forEach((wave, index) => {
145 const icons = waveIcons(wave)
146 const glyph = index <= lastGreen ? '━' : '┄'
147 const merged = icons.filter(icon => icon === '🟩').length
148 line += pad(`${aggregate(icons)} ${wave.name} ${merged}/${icons.length} `, 9) + glyph
149 })
150
151 return line + `▶ 🚀 ${plan.goal}`
152}
153
154/**
155 * The task rows under the rail: each wave hangs its tasks under its
156 * station, `├` on every row but the last, `╰` on the last, ten cells per
157 * column. Empty slots stay blank: a little drift reads better than filler
158 * squares. Ids of tasks in flight go bold.
159 */
160export function taskRows(plan: WaveBoardPlan): Segment[][] {
161 const depth = Math.max(0, ...plan.waves.map(wave => wave.tasks.length))
162 const rows: Segment[][] = []
163
164 for (let row = 0; row < depth; row += 1) {
165 const filled = plan.waves.map((wave, index) => (row < wave.tasks.length ? index : -1))
166 const last = Math.max(...filled)
167 const segments: Segment[] = [{ text: ' ' }]
168
169 plan.waves.slice(0, last + 1).forEach(wave => {
170 const task = wave.tasks[row]
171
172 if (task === undefined) {
173 segments.push({ text: pad('') })
174 return
175 }
176
177 const glyph = row === wave.tasks.length - 1 ? '╰' : '├'
178 const [id, icon] = task
179 const head = `${glyph}${icon} `
180 const tail = ' '.repeat(Math.max(0, 10 - cells(head) - cells(id)))
181
182 segments.push({ text: head })
183 segments.push({ text: id, bold: icon !== '🟩' && icon !== '🥚' })
184 segments.push({ text: tail })
185 })
186
187 rows.push(trimRow(segments))
188 }
189
190 return rows
191}
192
193/** Drops the trailing spaces of a row, segment by segment. */
194function trimRow(segments: Segment[]): Segment[] {
195 const trimmed = [...segments]
196
197 while (trimmed.length > 0) {
198 const lastSegment = trimmed[trimmed.length - 1]
199
200 if (lastSegment === undefined || lastSegment.text.trim() !== '') {
201 break
202 }
203
204 trimmed.pop()
205 }
206
207 const lastSegment = trimmed[trimmed.length - 1]
208
209 if (lastSegment !== undefined) {
210 trimmed[trimmed.length - 1] = { ...lastSegment, text: lastSegment.text.replace(/\s+$/, '') }
211 }
212
213 return trimmed
214}
215
216/**
217 * Greedy word wrap at `width` cells.
218 * @returns the lines, none when the text is empty
219 */
220export function wrapText(text: string, width: number): string[] {
221 const words = text.split(/\s+/).filter(word => word !== '')
222 const lines: string[] = []
223 let current = ''
224
225 for (const word of words) {
226 const candidate = current === '' ? word : `${current} ${word}`
227
228 if (cells(candidate) <= width || current === '') {
229 current = candidate
230 continue
231 }
232
233 lines.push(current)
234 current = word
235 }
236
237 if (current !== '') {
238 lines.push(current)
239 }
240
241 return lines
242}
243
244/**
245 * The boxed board: header, description, rail and rows, note and a
246 * thirty-tile bar. No right border, because rows carry different emoji
247 * counts and a right edge would zigzag.
248 * @param width cells of the rules; the band's `bodyColumns` less two
249 */
250export function renderFull(plan: WaveBoardPlan, width = DEFAULT_WIDTH): Segment[][] {
251 const { done, running, total } = progress(plan)
252 const rule = '─'.repeat(width)
253 const lines: Segment[][] = []
254
255 lines.push([{ text: `╭${rule}` }])
256 lines.push([
257 { text: '│ ' },
258 { text: plan.title, bold: true },
259 { text: ` · ${done} of ${total} · ${clock(plan.updatedAt)} · ${counts(plan)}` },
260 ])
261
262 // The plan's description, written once and shown under the title on at
263 // most two lines, behind a rule of its own.
264 const description = wrapText(plan.description, width - 4).slice(0, 2)
265
266 if (description.length > 0) {
267 lines.push([{ text: `├${rule}` }])
268 description.forEach(line => lines.push([{ text: `│ ${line}` }]))
269 }
270
271 lines.push([{ text: `├${rule}` }])
272 lines.push([{ text: `│${rail(plan)}` }])
273 taskRows(plan).forEach(row => lines.push([{ text: '│' }, ...row]))
274 lines.push([{ text: `├${rule}` }])
275 lines.push([{ text: `│ ${plan.note}` }])
276 lines.push([{ text: `│ ${tiles(done, running, total, 30)} ${done}/${total}` }])
277 lines.push([{ text: `╰${rule}` }])
278
279 return lines
280}
281
282/**
283 * The one-line rail for mode `bar`: project, title, time, one station per
284 * wave, the goal and ten tiles.
285 */
286export function renderBar(plan: WaveBoardPlan): string {
287 const { done, running, total } = progress(plan)
288 const stations = plan.waves
289 .map(wave => {
290 const icons = waveIcons(wave)
291 const merged = icons.filter(icon => icon === '🟩').length
292 return `${wave.name} ${aggregate(icons)} ${merged}/${icons.length}`
293 })
294 .join(' ┄ ')
295
296 return ` ${plan.project} · ${plan.title} · ${clock(plan.updatedAt)} · ${stations} ▶ 🚀 ${plan.goal} · ${tiles(done, running, total, 10)} ${done}/${total}`
297}
298
299/** The whole board as plain text, for the chat when the mode asks for it. */
300export function renderText(plan: WaveBoardPlan, width = DEFAULT_WIDTH): string {
301 return renderFull(plan, width)
302 .map(row => row.map(segment => segment.text).join(''))
303 .join('\n')
304}
305
306/**
307 * A fresh plan from the tool's input: every task starts pending.
308 * @param now epoch milliseconds of the write
309 */
310export function newPlan(input: PlanInput, now: number): WaveBoardPlan {
311 const waves = input.waves.map(wave => ({
312 name: wave.name,
313 tasks: wave.tasks.map((id): WaveBoardTask => [id, '🥚']),
314 }))
315
316 return {
317 project: input.project,
318 title: input.title,
319 goal: input.goal,
320 description: input.description ?? '',
321 note: input.note ?? '',
322 waves,
323 updatedAt: now,
324 }
325}
326
327/**
328 * The plan after the updates: a new object, the old one untouched.
329 * @returns the plan and the task ids the input named but the plan lacks
330 */
331export function applyUpdates(plan: WaveBoardPlan, input: SetInput, now: number): { plan: WaveBoardPlan; unknown: string[] } {
332 const tasks = input.tasks ?? {}
333 const seen = new Set<string>()
334 const waves = plan.waves.map(wave => ({
335 name: wave.name,
336 tasks: wave.tasks.map((task): WaveBoardTask => {
337 const icon = tasks[task[0]]
338
339 if (icon === undefined) {
340 return [task[0], task[1]]
341 }
342
343 seen.add(task[0])
344 return [task[0], icon]
345 }),
346 }))
347 const unknown = Object.keys(tasks).filter(id => !seen.has(id))
348
349 return {
350 plan: {
351 ...plan,
352 waves,
353 note: input.note ?? plan.note,
354 goal: input.goal ?? plan.goal,
355 title: input.title ?? plan.title,
356 description: input.description ?? plan.description,
357 updatedAt: now,
358 },
359 unknown,
360 }
361}
362
363/**
364 * The line the tools append for the model: what the current mode asks of
365 * the chat, so the model never has to read a settings file.
366 */
367export function modeHint(mode: WaveBoardMode): string {
368 switch (mode) {
369 case 'off':
370 return 'mode off: the board is not drawn above the prompt; show it in the chat only when the user asks (mcp__taskrail__show).'
371 case 'bar':
372 return 'mode bar: the one-line rail is already above the prompt; do not draw the board in the chat.'
373 case 'full':
374 return 'mode full: the board is already above the prompt; do not draw it in the chat.'
375 case 'both':
376 return 'mode both: the board is above the prompt AND is drawn in the chat at every milestone and close (mcp__taskrail__show).'
377 }
378}
379
380/** True when `value` is one of the four modes. */
381export function isMode(value: unknown): value is WaveBoardMode {
382 return typeof value === 'string' && (MODES as readonly string[]).includes(value)
383}
384types/index.d.ts 65 lines1/**
2 * What the band shows: nothing, the one-line rail, the whole board, or the
3 * whole board here and also in the chat at every milestone (the model's
4 * side of `both`; the mod only tells it the mode).
5 */
6export type WaveBoardMode = 'off' | 'bar' | 'full' | 'both'
7
8/**
9 * One task of the plan: its id as it appears in the map and its state icon.
10 * A tuple: compact in the store and cheap to compare.
11 */
12export type WaveBoardTask = [id: string, icon: string]
13
14/** One wave: the tasks that run in parallel under one station of the rail. */
15export type WaveBoardWave = { name: string; tasks: WaveBoardTask[] }
16
17/** The plan of one session. */
18export type WaveBoardPlan = {
19 project: string
20 title: string
21 goal: string
22 description: string
23 note: string
24 waves: WaveBoardWave[]
25 /** Epoch milliseconds of the last write, shown as HH:MM in the header. */
26 updatedAt: number
27}
28
29/** What the `plan` tool receives: the plan's words and its waves with task ids. */
30export type PlanInput = {
31 project: string
32 title: string
33 goal: string
34 description?: string
35 note?: string
36 waves: { name: string; tasks: string[] }[]
37}
38
39/** What the `set` tool receives: icons per task id and the optional text fields. */
40export type SetInput = {
41 tasks?: Record<string, string>
42 note?: string
43 goal?: string
44 title?: string
45 description?: string
46}
47
48declare module 'claude-code' {
49 // The mod's own tools, by the name the engine gives them. The type layer
50 // the engine lays only knows the MCP servers a session had connected, so
51 // without these entries `tool.call` and the tests reject the three names.
52 interface McpToolInputs {
53 'mcp__taskrail__plan': PlanInput
54 'mcp__taskrail__set': SetInput
55 'mcp__taskrail__show': {}
56 }
57
58 interface PluginState {
59 'taskrail': {
60 plan: WaveBoardPlan | null
61 mode: WaveBoardMode
62 }
63 }
64}
65