SLOPSHOPPER

openspec-dashboard

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

newpaneguardcommandtoaststatus
★ 1v0.1.0MITupdated 2026-10-08JRoot3D/plugins-cc/plugins/openspec-dashboard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openspec-dashboard
│ ┃ OpenSpec ✕ › fix the failing auth test and add an audit log call │ ┃ [ Refresh ] │ ┃ No active changes in openspec/changes. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /openspec │ ⎿ openspec-dashboard: No active OpenSpec changes. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · OpenSpec
[ Refresh ] No active changes in openspec/changes.
README

plugins-cc

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-cc
Source 3 files
hooks/register.tsx 170 lines
1import { 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}
170
hooks/tasks.ts 31 lines
1import 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
31
types/index.d.ts 9 lines
1export 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