A compact pane beside the chat for the open vibe-coding round: progress of the round and of every slice in progress, a link to each slice's session, and one…

A compact pane beside the chat for the open round of vibe-coding. It is a Claude Code mod: it runs in the CLI, the Code tab of the Desktop app and the VS Code extension.
It is laid out like the app's context pane and refreshed every ten seconds while it is open. Two refreshes never run at once, and none runs while the pane is closed. Every row is plain text, with no timers of its own, so nothing on the pane goes blank under load.
↗, which brings the slice's session to the front.▶ per slice and a Start N button above the groups write a request into the prompt box to start one session per slice, at most three slices in progress at once. Each session is briefed with /slice NN parallel auto: it goes on with its plan's assumptions without an approval round, stops only on an open decision (NEEDS-DECISION), ends with SLICE-READY: NN, and the squash happens through the merge queue in the main checkout (slice-loop --merge, or the running loop), not in the session. Enter sends it as your message and Claude starts the sessions under the session's own permission check; a start straight from the pane would have no conversation behind it, and auto mode refuses such a call. A blocked slice joins the ready ones as soon as the slices it depends on are done; a toast says so, and another when a plan waits for approval.It reads work/_slices.json, work/checklist.md, work/rounds/, work/conventions.md and the slice-NN branches and worktrees of the repository the session runs in. The request asks for /slice NN parallel auto with the slice's goal, scope and acceptance cases, plus work/session-hints.md when the repository has one. Sessions are matched to slices by their title, Slice NN ….
claude plugin install slice-board@vompa
The pane opens only in the chat that runs the round: with /slices, /slice alone, /slice status, /slice close or /vibe-coding. A slice session works on /slice NN and gets no pane, and a session without the pane runs no git. After /reload-plugins a session that had the pane opens it again.
claude plugin validate plugins/slice-board
claude plugin test plugins/slice-boardhooks/register.tsx 452 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Board, SliceRow, SliceSession, SliceState } from '../types'
5
6const PANE = 'slice-board'
7const TITLE = 'Slices'
8const REFRESH_MS = 10_000
9const SESSIONS_MS = 30_000
10const STEPS = ['Plan', 'Red', 'Green', 'Checks', 'Commit']
11const LIST_SESSIONS = 'mcp__ccd_session_mgmt__list_sessions'
12const OPEN_SESSION = 'mcp__ccd_window__open_session_in'
13/** Parallel builds and test suites turn each other red under load: at most this many slices in progress at once (four were almost too many). */
14const MAX_PARALLEL = 3
15
16/** The groups of the pane in the order of the bar and the legend, with a fill for the desktop and a theme color for the terminal. */
17const GROUPS: { state: SliceState; label: string; fill: string; cell: string }[] = [
18 { state: 'done', label: 'Done', fill: '#34a06f', cell: 'success' },
19 { state: 'active', label: 'In progress', fill: '#d97757', cell: 'claude' },
20 { state: 'ready', label: 'Ready', fill: '#3b82f6', cell: 'blue' },
21 { state: 'blocked', label: 'Blocked', fill: '#b9b9b9', cell: 'inactive' },
22]
23
24const board = atom({ plugin: 'slice-board', key: 'board' } as const, null)
25const expanded = atom({ plugin: 'slice-board', key: 'expanded' } as const, { done: false, active: true, ready: false, blocked: false })
26/** Whether this session asked for the pane; a reload of the plugin opens it again. */
27const isWanted = atom({ plugin: 'slice-board', key: 'isWanted' } as const, false)
28
29/**
30 * The commands of the chat that runs a round: `/slice` alone, `/slice status`, `/slice close`, `/vibe-coding`. A slice
31 * session works on `/slice NN` and gets no pane.
32 */
33function asksForPane(text: string, command?: { name: string; args: string }): boolean {
34 const typed = /^\/([\w:-]+)\s*(.*)$/s.exec(text.trim())
35 const name = command?.name ?? typed?.[1] ?? ''
36 const args = (command?.args ?? typed?.[2] ?? '').trim()
37 if (name === 'slices' || name === 'vibe-coding' || name.endsWith(':vibe-coding')) return true
38 return name === 'slice' && /^(status|close)?(\s|$)/.test(args)
39}
40
41type Slice = {
42 nr: string
43 slug: string
44 name: string
45 round: string
46 deps: string
47}
48type SessionEntry = { sessionId?: string; title?: string; cwd?: string; link?: string; isRunning?: boolean; isArchived?: boolean }
49
50/** Ready slices and slices awaiting approval at the last refresh; a change is announced once. */
51let announced: { ready: Set<string>; approval: Set<string> } | null = null
52let sessionCache: { at: number; entries: SessionEntry[] } = { at: 0, entries: [] }
53
54const clip = (text: string, width: number) => (text.length > width ? `${text.slice(0, Math.max(1, width - 1))}…` : text)
55const samePath = (a: string, b: string) => a.replace(/\\/g, '/').replace(/\/$/, '').toLowerCase() === b.replace(/\\/g, '/').replace(/\/$/, '').toLowerCase()
56
57async function git($: EngineInterface, cwd: string, args: string[]): Promise<string> {
58 const { exitCode, stdout } = await $.process.run(['git', ...args], { cwd, timeoutMs: 15_000 })
59 return exitCode === 0 ? stdout.trim() : ''
60}
61
62async function readText($: EngineInterface, path: string): Promise<string | null> {
63 try {
64 return (await $.fs.read(path)) as string
65 } catch {
66 return null
67 }
68}
69
70/** Ticks per slice number from a checklist table: `| 12 web-money-pos | ☑ | ☐ | … |`. */
71function checklistTicks(text: string | null): Map<string, boolean[]> {
72 const ticks = new Map<string, boolean[]>()
73 for (const line of (text ?? '').split(/\r?\n/)) {
74 const cells = line.split('|').map(cell => cell.trim())
75 const nr = /^(\d{2,})\s/.exec(cells[1] ?? '')?.[1]
76 if (nr) ticks.set(nr, cells.slice(2, 2 + STEPS.length).map(cell => cell.includes('☑')))
77 }
78 return ticks
79}
80
81function depsOf(slice: Slice): string[] {
82 return slice.deps.split(/[,\s]+/).filter(dep => /^\d+$/.test(dep))
83}
84
85/** Main checkout and the worktree path of every branch, from `git worktree list --porcelain`. */
86function worktrees(porcelain: string): { main: string; byBranch: Map<string, string> } {
87 const byBranch = new Map<string, string>()
88 let main = ''
89 let path = ''
90 for (const line of porcelain.split(/\r?\n/)) {
91 if (line.startsWith('worktree ')) {
92 path = line.slice('worktree '.length)
93 if (!main) main = path
94 } else if (line.startsWith('branch refs/heads/')) {
95 byBranch.set(line.slice('branch refs/heads/'.length), path)
96 }
97 }
98 return { main, byBranch }
99}
100
101async function openRound($: EngineInterface, root: string): Promise<string | null> {
102 const files = await $.fs.list(`${root}/work/rounds`).catch(() => [])
103 for (const file of files.filter(entry => entry.name.endsWith('.md')).sort((a, b) => a.name.localeCompare(b.name))) {
104 const text = await readText($, `${root}/work/rounds/${file.name}`)
105 if (text?.split(/\r?\n/, 1)[0]?.trim() === 'Status: open') return /^R\d+/.exec(file.name)?.[0] ?? file.name
106 }
107 return null
108}
109
110/** The desktop app's sessions titled "Slice NN …", those working in this repository before the others; `isHere` says one works here. */
111function sessionsBySlice(entries: SessionEntry[], root: string): Map<string, { session: SliceSession; isHere: boolean }> {
112 const found = new Map<string, { session: SliceSession; isHere: boolean }>()
113 for (const entry of entries) {
114 const nr = /^Slice (\d{2,})\b/.exec(entry.title ?? '')?.[1]
115 if (!nr || !entry.link || !entry.sessionId || entry.isArchived) continue
116 const isHere = samePath(entry.cwd ?? '', root)
117 const known = found.get(nr)
118 if (!known || (isHere && !known.isHere)) {
119 const session = { id: entry.sessionId, title: entry.title ?? '', link: entry.link, isRunning: entry.isRunning === true }
120 found.set(nr, { session, isHere })
121 }
122 }
123 return found
124}
125
126/** Lists the app's sessions at most every 30 s (every refresh while a start is pending), and only where the permission check allows it without asking. */
127async function appSessions($: EngineInterface, now: number): Promise<SessionEntry[]> {
128 if (now - sessionCache.at < SESSIONS_MS) return sessionCache.entries
129 sessionCache = { at: now, entries: sessionCache.entries }
130 try {
131 const input = { limit: 50 }
132 const { decision } = await $.tool.check({ tool: LIST_SESSIONS, input })
133 if (decision !== 'allow') return sessionCache.entries
134 const called = (await $.tool.call({ tool: LIST_SESSIONS, ...input })) as { text?: string; result?: unknown }
135 const content = Array.isArray(called.result) ? (called.result as { text?: string }[]).find(part => typeof part.text === 'string')?.text : undefined
136 const entries = JSON.parse(called.text ?? content ?? '[]') as SessionEntry[]
137 sessionCache = { at: now, entries: Array.isArray(entries) ? entries : [] }
138 } catch (error) {
139 $.ui.log(`slice-board: sessions not listed: ${String(error)}`, { to: 'debug' })
140 }
141 return sessionCache.entries
142}
143
144async function collect($: EngineInterface, cwd: string): Promise<Board | null> {
145 const { main: root, byBranch } = worktrees(await git($, cwd, ['worktree', 'list', '--porcelain']))
146 if (!root) return null
147 const slicesText = await readText($, `${root}/work/_slices.json`)
148 if (!slicesText) return null
149 const now = await $.clock.now()
150 const conventions = (await readText($, `${root}/work/conventions.md`)) ?? ''
151 const integration = /(?:Integrationsbranch|Integration branch)[^`\n]*`([^`]+)`/i.exec(conventions)?.[1] ?? 'main'
152 const round = await openRound($, root)
153 if (!round) return { root, round: '–', integration, rows: [], updatedAt: now, problem: 'No open round.' }
154
155 const slices = (JSON.parse(slicesText) as Slice[]).filter(slice => slice.round === round)
156 const mainTicks = checklistTicks(await readText($, `${root}/work/checklist.md`))
157 const isDone = (nr: string) => (mainTicks.get(nr) ?? []).length === STEPS.length && mainTicks.get(nr)!.every(Boolean)
158 const branches = new Set((await git($, root, ['branch', '--list', 'slice-*', '--format=%(refname:short)'])).split(/\r?\n/).filter(Boolean))
159 const sessions = sessionsBySlice(await appSessions($, now), root)
160
161 const rows: SliceRow[] = []
162 for (const slice of slices) {
163 const branch = `slice-${slice.nr}`
164 const tree = byBranch.get(branch)
165 const done = isDone(slice.nr)
166 const hasBranch = !done && (branches.has(branch) || byBranch.has(branch))
167 let ticks = mainTicks.get(slice.nr) ?? STEPS.map(() => false)
168 let ahead = 0
169 let hasPlan = false
170 if (hasBranch) {
171 if (tree) ticks = checklistTicks(await readText($, `${tree}/work/checklist.md`)).get(slice.nr) ?? ticks
172 ahead = Number(await git($, root, ['rev-list', '--count', `${integration}..${branch}`])) || 0
173 const plans = await $.fs.list(`${tree ?? root}/work/plans`).catch(() => [])
174 hasPlan = plans.some(entry => entry.name.endsWith(`-slice-${slice.nr}.md`))
175 }
176 const waitingFor = depsOf(slice).filter(dep => !isDone(dep))
177 // A session of this repository titled for the slice takes its slot in every pane, also before it has created its branch.
178 const found = sessions.get(slice.nr)
179 const isClaimed = !done && found?.isHere === true
180 const state: SliceState = done ? 'done' : hasBranch || isClaimed || ticks.some(Boolean) ? 'active' : waitingFor.length ? 'blocked' : 'ready'
181 const session = state === 'active' ? (found?.session ?? null) : null
182 rows.push({ nr: slice.nr, slug: slice.slug, name: slice.name, state, ticks, waitingFor, ahead, hasPlan, session })
183 }
184 return { root, round, integration, rows, updatedAt: now, problem: null }
185}
186
187/** What a slice in progress is doing, from its session and its checklist ticks. */
188function phase(row: SliceRow): { label: string; color: string } {
189 if (row.session?.isRunning) return { label: 'working', color: 'claude' }
190 if (row.hasPlan && !row.ticks[0]) return { label: 'awaiting approval', color: 'warning' }
191 if (row.ticks.every(Boolean)) return { label: 'ready to merge', color: 'success' }
192 return { label: row.session ? 'idle' : 'no session', color: 'inactive' }
193}
194
195/** How far a slice is: its ticked steps, half a step more while its session works. */
196function progress(row: SliceRow): number {
197 const ticked = row.ticks.filter(Boolean).length
198 return Math.min(1, (ticked + (row.session?.isRunning && ticked < STEPS.length ? 0.5 : 0)) / STEPS.length)
199}
200
201/** A toast once when a slice becomes ready to start or its plan starts waiting for approval; nothing on the first refresh. */
202function announce($: EngineInterface, data: Board | null) {
203 if (!data) return
204 const ready = new Set(data.rows.filter(row => row.state === 'ready').map(row => row.nr))
205 const approval = new Set(data.rows.filter(row => row.state === 'active' && phase(row).label === 'awaiting approval').map(row => row.nr))
206 if (announced) {
207 const newlyReady = [...ready].filter(nr => !announced!.ready.has(nr))
208 if (newlyReady.length) $.ui.toast(`Ready to start: slice ${newlyReady.join(', ')}`)
209 for (const nr of approval) if (!announced.approval.has(nr)) $.ui.toast(`Slice ${nr}: plan awaits your approval`)
210 }
211 announced = { ready, approval }
212}
213
214/** Whether a refresh runs or waits: never two at once, none while the pane is closed. */
215let isRefreshing = false
216let isPaneOpen = false
217let pending: ReturnType<EngineInterface['clock']['after']> | undefined
218
219async function refresh($: EngineInterface, cwd: string) {
220 if (isRefreshing) return
221 isRefreshing = true
222 try {
223 const next = await collect($, cwd)
224 announce($, next)
225 await update($, board, () => next)
226 } catch (error) {
227 $.ui.log(`slice-board: ${String(error)}`, { to: 'debug' })
228 } finally {
229 isRefreshing = false
230 }
231}
232
233/** Waits 10 s, refreshes, and waits again, while the pane is open; closing the pane cancels the wait. */
234function scheduleRefresh($: EngineInterface) {
235 if (pending || !isPaneOpen) return
236 pending = $.clock.after(REFRESH_MS, () => {
237 pending = undefined
238 if (isPaneOpen) void refresh($, paneCwd).then(() => scheduleRefresh($))
239 })
240}
241
242/** Brings a session to the front: the app's window tool where it answers, else the session's `claude://` link through the system. */
243async function openSession($: EngineInterface, session: SliceSession) {
244 try {
245 const shown = (await $.tool.call({ tool: OPEN_SESSION, session_id: session.id, target: 'focus' })) as { deny?: string; isError?: boolean }
246 if (!shown.deny && !shown.isError) return
247 } catch {
248 // fall through to the link
249 }
250 for (const argv of [['explorer.exe', session.link], ['open', session.link], ['xdg-open', session.link]]) {
251 const opened = await $.process.run(argv, { timeoutMs: 10_000 }).catch(() => null)
252 if (opened) return
253 }
254 $.ui.toast(`Open "${session.title}" from the sidebar.`)
255}
256
257/**
258 * Puts a request into the person's prompt box to start one session per slice; Enter makes it their message and Claude
259 * starts the sessions with the session's own permission check. A start straight from the pane has no conversation
260 * behind it, and the auto mode classifier refuses it.
261 */
262async function requestStart($: EngineInterface, round: string, rows: SliceRow[]) {
263 const list = rows.map(row => `${row.nr} ${row.name}`).join('; ')
264 const text =
265 `Start one separate session per slice of round ${round} for: ${list}. ` +
266 `Each in this repository's main checkout without an app worktree, on the slice's "model" from work/_slices.json ` +
267 `(never this session's model), titled "Slice NN – <name> (${round})", ` +
268 `briefed with "/slice NN parallel auto" (the session goes on with its plan's assumptions and stops only on an open decision), ` +
269 `the slice's goal, scope, not-building list and acceptance cases from work/_slices.json, ` +
270 `the parallel rules (own branch and worktree, never push) and work/session-hints.md. ` +
271 `Each session ends with SLICE-READY: NN; the squash happens through the merge queue in the main checkout ` +
272 `(slice-loop --merge, or the running loop), never in the session itself.`
273 const box = await $.prompt.read()
274 const { isFilled } = await $.prompt.fill(box.text.trim() ? { text: `\n${text}`, mode: 'append' } : { text })
275 $.ui.toast(isFilled ? 'Start request is in the prompt box: Enter sends it.' : 'The prompt box is busy: close the dialog and press Start again.')
276}
277
278/** The session's folder; its refresh runs only after the pane was asked for. */
279let paneCwd = ''
280
281/** Opens the pane in this session and starts its refresh; sessions that never ask run no git at all. */
282async function showPane($: EngineInterface) {
283 paneCwd ||= await $.session.cwd()
284 await update($, isWanted, () => true)
285 sessionCache = { at: 0, entries: sessionCache.entries }
286 isPaneOpen = true
287 await refresh($, paneCwd)
288 scheduleRefresh($)
289 await $.ui.open({ id: PANE, title: TITLE })
290}
291
292export const register: Register = on => {
293 on('session.start', async ($, e, next) => {
294 const started = await next(e)
295 paneCwd = e.cwd
296 await $.command.register({ name: 'slices', description: 'Show the slices of the open round in a pane' })
297 if (await read($, isWanted)) await showPane($)
298 return started
299 })
300
301 // The pane belongs to the chat that runs the round, not to every session in the repository.
302 on('prompt.submit', async ($, e, next) => {
303 if (asksForPane(e.text, e.command)) await showPane($)
304 return next(e)
305 })
306
307 // A closed pane stops the refresh; /slices or a round command opens it again.
308 on('ui.close', async ($, e, next) => {
309 if (e.id === PANE) {
310 isPaneOpen = false
311 pending?.cancel()
312 pending = undefined
313 }
314 return next(e)
315 })
316
317 on('command.run', { command: 'slices' }, async $ => {
318 await showPane($)
319 return { text: 'Slice pane opened.' }
320 })
321
322 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
323 const elements = $.ui.resolve(e)
324 const { Box, Text, Button } = elements
325 // Air between rows and groups on surfaces that draw in pixels; the terminal keeps one line per row.
326 const air = e.surface === 'terminal' ? 0 : 1
327 const data = await read($, board)
328 const open = await read($, expanded)
329 if (!data) return <Text dimColor>No round here: work/_slices.json not found.</Text>
330 if (data.problem) return <Text dimColor>{data.problem}</Text>
331
332 const total = data.rows.length || 1
333 const rowsOf = (state: SliceState) => data.rows.filter(row => row.state === state)
334 const done = rowsOf('done')
335 const ready = rowsOf('ready')
336 const slots = Math.max(0, MAX_PARALLEL - rowsOf('active').length)
337 const startable = ready.slice(0, slots)
338 const percentOf = (count: number) => `${Math.round((count / total) * 100)} %`
339 const toggle = (state: SliceState) => update($, expanded, current => ({ ...current, [state]: !current[state] }))
340 const start = (rows: SliceRow[]) => requestStart($, data.round, rows)
341
342 /** Count and share, right-aligned in two fixed columns like the context pane's legend. */
343 const columns = (count: number) => (
344 <Box flexShrink={0}>
345 <Box width={5} justifyContent="flex-end">
346 <Text color="subtle">
347 {count}
348 </Text>
349 </Box>
350 <Box width={7} justifyContent="flex-end">
351 <Text color="subtle">
352 {percentOf(count)}
353 </Text>
354 </Box>
355 </Box>
356 )
357
358 /** One slice under its group: what it is, and on the right what it waits for or does. */
359 const sliceRow = (row: SliceRow) => {
360 const now = phase(row)
361 const step = row.ticks.every(Boolean) ? 'merge' : STEPS[row.ticks.findIndex(tick => !tick)]
362 // Every ready slice can be started while a slot is free; the person picks which.
363 const canStart = row.state === 'ready' && slots > 0
364 const right =
365 row.state === 'active' ? (
366 <Box flexShrink={0} alignItems="center" gap={1}>
367 <Text color="subtle">
368 {step} · {Math.round(progress(row) * 100)} %
369 </Text>
370 {row.session && <Button key={`open-${row.nr}`} plain label={'↗'} onPress={() => openSession($, row.session!)} />}
371 </Box>
372 ) : row.state === 'ready' ? (
373 <Box flexShrink={0}>
374 {canStart && <Button key={`start-${row.nr}`} plain label={'▶'} onPress={() => start([row])} />}
375 </Box>
376 ) : row.state === 'blocked' ? (
377 <Text color="subtle">
378 {'←'} {row.waitingFor.join(', ')}
379 </Text>
380 ) : null
381 const isDim = row.state === 'done' || row.state === 'blocked' || (row.state === 'ready' && !canStart)
382 const left = (
383 <Text wrap="truncate-end" dimColor={isDim}>
384 {row.state === 'active' && <Text color={now.color}>{'\u25cf'} </Text>}
385 <Text color="subtle">{row.nr} </Text>
386 {row.state === 'ready' ? row.name : row.slug}
387 </Text>
388 )
389 return (
390 <Box key={`row-${row.nr}`} flexDirection="column" paddingLeft={4} hover={{ backgroundColor: 'rgba(127,127,127,0.08)' }}>
391 <Box justifyContent="space-between" alignItems="center" gap={1}>
392 {/* minWidth 0: without it a flex item never shrinks below its text, the name pushes the buttons out of the pane. */}
393 <Box flexGrow={1} flexShrink={1} minWidth={0} overflow="hidden">
394 {left}
395 </Box>
396 {right}
397 </Box>
398 </Box>
399 )
400 }
401
402 return (
403 <Box flexDirection="column">
404 <Box justifyContent="space-between">
405 <Text>Round {data.round}</Text>
406 <Text color="subtle">
407 {done.length} / {data.rows.length} done
408 </Text>
409 </Box>
410 <Box marginTop={1} marginBottom={1}>
411 {GROUPS.filter(group => rowsOf(group.state).length > 0).map(group => {
412 const share = rowsOf(group.state).length / total
413 return (
414 <Box key={`bar-${group.state}`} width={0} flexGrow={rowsOf(group.state).length} backgroundColor={group.fill} justifyContent="center" overflow="hidden">
415 <Text color="#ffffff" wrap="truncate-end">
416 {share >= 0.08 ? percentOf(rowsOf(group.state).length) : ' '}
417 </Text>
418 </Box>
419 )
420 })}
421 </Box>
422
423 {/* Start above the groups, so it stays in view however long the lists get. */}
424 {ready.length > 0 && (
425 <Box justifyContent="space-between" alignItems="center" marginBottom={1}>
426 <Text color="subtle">{slots > 0 ? `${slots} of ${MAX_PARALLEL} slots free` : `All ${MAX_PARALLEL} slots in use`}</Text>
427 {startable.length > 0 && <Button key="start-ready" label={`Start ${startable.length}`} onPress={() => start(startable)} />}
428 </Box>
429 )}
430
431 {GROUPS.map(group => {
432 const rows = rowsOf(group.state)
433 const isOpen = open[group.state] && rows.length > 0
434 return (
435 <Box key={`group-${group.state}`} flexDirection="column" marginTop={air} gap={air}>
436 <Box justifyContent="space-between" alignItems="center">
437 <Box alignItems="center" gap={1}>
438 <Text color={e.surface === 'terminal' ? group.cell : group.fill}>{'■'}</Text>
439 <Button key={`toggle-${group.state}`} plain label={`${isOpen ? '▾' : '›'} ${group.label}`} onPress={() => toggle(group.state)} />
440 </Box>
441 {columns(rows.length)}
442 </Box>
443 {isOpen && rows.map(sliceRow)}
444 </Box>
445 )
446 })}
447
448 </Box>
449 )
450 })
451}
452types/index.d.ts 41 lines1export type SliceState = 'done' | 'active' | 'ready' | 'blocked'
2
3/** The Claude session that works on a slice, matched by its title "Slice NN …". */
4export type SliceSession = { id: string; title: string; link: string; isRunning: boolean }
5
6export type SliceRow = {
7 nr: string
8 slug: string
9 name: string
10 state: SliceState
11 /** Ticks of the five checklist columns (Plan, Tests red, Green, Checks, Commit), from the slice's worktree when it has one. */
12 ticks: boolean[]
13 /** Dependencies that are not done yet. */
14 waitingFor: string[]
15 /** Commits on slice-NN ahead of the integration branch. */
16 ahead: number
17 hasPlan: boolean
18 session: SliceSession | null
19}
20
21export type Board = {
22 root: string
23 round: string
24 integration: string
25 rows: SliceRow[]
26 updatedAt: number
27 problem: string | null
28}
29
30declare module 'claude-code' {
31 interface PluginState {
32 'slice-board': {
33 board: Board | null
34 /** Which groups of the pane are unfolded. */
35 expanded: Record<SliceState, boolean>
36 /** Whether this session asked for the pane. */
37 isWanted: boolean
38 }
39 }
40}
41