OpenSpec changes pane: task progress, tick tasks, run apply/verify/archive or a single task

A Claude Code plugin marketplace.
arch — multi-session architecture exploration: raw project ideas → idea nodes → decisions → feature briefs for OpenSpec changes; /arch-graph shows the dependency graph in a side pane. See plugins/arch/README.md.openspec-dashboard — a mod: /openspec opens a pane with every active OpenSpec change's task progress; tick tasks, run /opsx:apply / verify / archive, or a single task./plugin marketplace add JRoot3D/plugins-cc
/plugin install arch@plugins-cc
/plugin install openspec-dashboard@plugins-cchooks/register.tsx 170 lines1import { update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Change, Task } from '../types'
5import { doneOf, parseTasks, taskId, toggle } from './tasks'
6
7const PANE = 'openspec'
8const ROOT = 'openspec/changes'
9const changesRef = { plugin: 'openspec-dashboard', key: 'changes' } as const
10const openRef = { plugin: 'openspec-dashboard', key: 'open' } as const
11
12const tasksPath = (change: string) => `${ROOT}/${change}/tasks.md`
13const fit = (text: string, width: number) => (text.length > width ? `${text.slice(0, width - 1)}…` : text)
14const bar = (done: number, total: number) => (total ? '█'.repeat(Math.round((done / total) * 8)).padEnd(8, '░') : '')
15
16async function refresh($: EngineInterface) {
17 const dirs = (await $.fs.exists(ROOT)) ? await $.fs.list(ROOT) : []
18 const changes: Change[] = await Promise.all(
19 dirs
20 .filter(d => d.kind === 'dir' && d.name !== 'archive')
21 .map(async d => {
22 const path = tasksPath(d.name)
23 const tasks = (await $.fs.exists(path)) ? parseTasks(String(await $.fs.read(path))) : []
24 return { name: d.name, tasks }
25 }),
26 )
27 await $.state.set(changesRef, changes)
28 $.ui.status(
29 changes.length ? `openspec: ${changes.map(c => `${c.name} ${doneOf(c)}/${c.tasks.length}`).join(' · ')}` : undefined,
30 )
31
32 return changes
33}
34
35async function tick($: EngineInterface, change: string, task: Task) {
36 const path = tasksPath(change)
37 const text = toggle(String(await $.fs.read(path)), task)
38 if (text === undefined) $.ui.toast('tasks.md changed meanwhile: refreshed, press again')
39 else await $.fs.write(path, text)
40 await refresh($)
41}
42
43// Not awaited by the Button: the command is queued and runs once the session is idle.
44const run = ($: EngineInterface, command: string, args: string) => {
45 void $.command.run({ command, args }).catch(err => $.ui.toast(`/${command} failed: ${err}`))
46}
47
48const runTask = ($: EngineInterface, change: string, task: Task) =>
49 run($, 'opsx:apply', `${change}: do only task ${taskId(task) ?? `"${task.text}"`}, tick it in tasks.md, then stop`)
50
51export const register: Register = on => {
52 on('session.start', async ($, e, next) => {
53 await $.command.register({
54 name: 'openspec',
55 description: 'OpenSpec changes pane: task progress, tick tasks, apply/verify/archive',
56 })
57 await refresh($)
58
59 return next(e)
60 })
61
62 on('command.run', { command: 'openspec' }, async $ => {
63 // a dispatch reads state as of its start, so use what refresh read
64 const changes = await refresh($)
65 await $.ui.open({ id: PANE, title: 'OpenSpec' })
66
67 return {
68 text: changes.length
69 ? changes.map(c => `${c.name}: ${doneOf(c)}/${c.tasks.length} tasks`).join('\n')
70 : 'No active OpenSpec changes.',
71 }
72 })
73
74 // The model ticks tasks with Edit/Write during /opsx:apply: follow it live.
75 on('tool.call', async ($, e, next) => {
76 const result = await next(e)
77 const isEdit = ['Edit', 'MultiEdit', 'Write'].includes(String(e.tool))
78 if (isEdit && 'file_path' in e && String(e.file_path).includes(`${ROOT}/`)) await refresh($)
79
80 return result
81 })
82
83 // Catches the rest (an archive moves a folder through Bash).
84 on('turn.complete', async ($, e, next) => {
85 const result = await next(e)
86 await refresh($)
87
88 return result
89 })
90
91 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
92 const { Box, Button, Text } = $.ui.resolve(e)
93 const { value: changes = [] } = await $.state.get(changesRef)
94 const { value: open = null } = await $.state.get(openRef)
95 const width = Math.max(20, e.props.bodyColumns)
96
97 return (
98 <Box flexDirection="column">
99 <Button key="refresh" hotkey="r" label="Refresh" onPress={() => refresh($)} />
100 {changes.length === 0 && <Text dimColor>No active changes in {ROOT}.</Text>}
101 {changes.map(change => {
102 const done = doneOf(change)
103 const total = change.tasks.length
104 const isComplete = total > 0 && done === total
105 const isOpen = open === change.name
106
107 return (
108 <Box flexDirection="column" marginTop={1}>
109 <Button
110 plain
111 key={`open:${change.name}`}
112 label={fit(`${isOpen ? '▾' : '▸'} ${change.name} ${done}/${total} ${bar(done, total)}`, width)}
113 onPress={() => update($, openRef, current => (current === change.name ? null : change.name))}
114 />
115 <Box gap={1}>
116 {!isComplete && (
117 <Button
118 key={`apply:${change.name}`}
119 variant="primary"
120 label="Apply"
121 onPress={() => run($, 'opsx:apply', change.name)}
122 />
123 )}
124 <Button key={`verify:${change.name}`} label="Verify" onPress={() => run($, 'opsx:verify', change.name)} />
125 {isComplete && (
126 <Button
127 key={`archive:${change.name}`}
128 variant="primary"
129 label="Archive"
130 onPress={() => run($, 'opsx:archive', change.name)}
131 />
132 )}
133 </Box>
134 {isOpen &&
135 change.tasks.map((task, i) => (
136 <Box flexDirection="column">
137 {task.section !== change.tasks[i - 1]?.section && (
138 <Text bold dimColor>
139 {fit(task.section, width)}
140 </Text>
141 )}
142 <Box gap={1}>
143 {task.isDone ? (
144 <Text> </Text>
145 ) : (
146 <Button
147 plain
148 key={`run:${change.name}:${task.line}`}
149 label="▶"
150 onPress={() => runTask($, change.name, task)}
151 />
152 )}
153 <Button
154 plain
155 dimColor={task.isDone}
156 key={`tick:${change.name}:${task.line}`}
157 label={fit(`${task.isDone ? '[x]' : '[ ]'} ${task.text}`, width - 2)}
158 onPress={() => tick($, change.name, task)}
159 />
160 </Box>
161 </Box>
162 ))}
163 </Box>
164 )
165 })}
166 </Box>
167 )
168 })
169}
170hooks/tasks.ts 31 lines1import type { Change, Task } from '../types'
2
3// `- [ ] 1.1 Text`; the trailing \s* also eats a CRLF's \r
4const TASK = /^\s*- \[([ xX])\] (.*?)\s*$/
5
6export const parseTasks = (md: string): Task[] => {
7 let section = ''
8 const tasks: Task[] = []
9 md.split('\n').forEach((row, line) => {
10 if (row.startsWith('## ')) section = row.slice(3).trim()
11 const m = TASK.exec(row)
12 if (m) tasks.push({ line, section, text: m[2] ?? '', isDone: m[1] !== ' ' })
13 })
14 return tasks
15}
16
17// Flips the checkbox on `task.line` only while that line still holds the same task;
18// undefined when the file moved on since it was read.
19export const toggle = (md: string, task: Task): string | undefined => {
20 const rows = md.split('\n')
21 const row = rows[task.line] ?? ''
22 const m = TASK.exec(row)
23 if (!m || m[2] !== task.text) return undefined
24 rows[task.line] = row.replace(/\[([ xX])\]/, (_, c) => (c === ' ' ? '[x]' : '[ ]'))
25 return rows.join('\n')
26}
27
28export const taskId = (task: Task) => /^\d+(\.\d+)*/.exec(task.text)?.[0]
29
30export const doneOf = (change: Change) => change.tasks.filter(t => t.isDone).length
31types/index.d.ts 9 lines1export type Task = { line: number; section: string; text: string; isDone: boolean }
2export type Change = { name: string; tasks: Task[] }
3
4declare module 'claude-code' {
5 interface PluginState {
6 'openspec-dashboard': { changes: Change[]; open: string | null }
7 }
8}
9