A pane showing each QRSPI task in thoughts/: which phase it is in, what blocks it, and the /qrspi:next command to run

A Claude Code mod — a plugin of function hooks — that draws the QRSPI tasks under thoughts/ as a pane. For each task: the phases Q R D S P done (green), the current one (yellow) with the boxes still open under its ## Status, the Implement steps done out of those in 99-progress.md, and the /qrspi:next command to run next.
It reads the artifacts the way /qrspi:next does — a phase is done when every Status box is ticked and no _(to be filled placeholder is left — and never writes anything.
/plugin install qrspi-pane@allan-nava
/qrspi-status # the session's project
/qrspi-status ../other-repo # another project's thoughts/
It refreshes after every turn and every 10 seconds. A pane opened by the command seats at any width.
Developing it: claude --plugin-dir plugins/qrspi-pane loads it from the checkout and reloads it on save; claude plugin validate plugins/qrspi-pane checks the module before a session does. /plugin-types writes the engine's types under .claude-plugin/types/ (gitignored), which tsconfig.json uses.
hooks/register.tsx 205 lines1import type { Register } from 'claude-code'
2
3import type { PhaseState, Snapshot, Task } from '../types'
4
5// The pane reads thoughts/<task>/ the way /qrspi:next does: a phase is done when every
6// box under its `## Status` heading is ticked and no `_(to be filled` placeholder is
7// left; Implement is the step table of 99-progress.md. It only reads, never writes.
8
9const PANE = 'qrspi'
10const PHASES: [string, string, string][] = [
11 ['Q', 'Questions', '00-questions.md'],
12 ['R', 'Research', '01-research.md'],
13 ['D', 'Design', '02-design.md'],
14 ['S', 'Structure', '03-structure.md'],
15 ['P', 'Plan', '04-plan.md'],
16]
17const EMPTY: Snapshot = { root: '', hasThoughts: false, tasks: [], readAt: 0 }
18const SNAPSHOT = { plugin: 'qrspi-pane', key: 'snapshot' } as const
19// The project the pane reads: the session's root unless /qrspi-status <path> named another.
20const ROOT = { plugin: 'qrspi-pane', key: 'root' } as const
21
22// The lines under `## Status`, up to the next `## ` heading.
23export function statusBoxes(text: string): { done: boolean; label: string }[] {
24 const at = text.search(/^## Status\s*$/m)
25 if (at < 0) return []
26 const out: { done: boolean; label: string }[] = []
27 for (const line of text.slice(at).split('\n').slice(1)) {
28 if (/^## /.test(line)) break
29 const m = /^- \[( |x|X)\] (.*)$/.exec(line)
30 if (m) out.push({ done: m[1] !== ' ', label: m[2] })
31 }
32 return out
33}
34
35export function phaseState(letter: string, name: string, text: string | null): PhaseState {
36 if (text === null) return { letter, name, state: 'absent', open: [] }
37 const boxes = statusBoxes(text)
38 const open = boxes.filter(b => !b.done).map(b => b.label)
39 const placeholder = text.includes('_(to be filled')
40 const done = boxes.length > 0 && open.length === 0 && !placeholder
41 if (placeholder && open.length === 0) open.push('placeholders left (_(to be filled …)_)')
42 return { letter, name, state: done ? 'done' : 'open', open }
43}
44
45// Rows of the step table: `| S1 | ✅ done | …`. A step counts as done when its status
46// cell carries the done glyph or the word.
47export function stepCounts(text: string | null): { done: number; total: number } {
48 if (text === null) return { done: 0, total: 0 }
49 let done = 0
50 let total = 0
51 for (const line of text.split('\n')) {
52 const m = /^\|\s*S\d+\s*\|\s*([^|]*)\|/.exec(line)
53 if (!m) continue
54 total++
55 if (/✅|\bdone\b/i.test(m[1])) done++
56 }
57 return { done, total }
58}
59
60async function readOrNull($: any, path: string) {
61 try {
62 return (await $.fs.read(path)) as string
63 } catch {
64 return null
65 }
66}
67
68async function scan($: any): Promise<Snapshot> {
69 const { value: chosen = '' } = await $.state.get(ROOT)
70 const root: string = chosen || (await $.session.root())
71 const thoughts = `${root}/thoughts`
72 if (!(await $.fs.exists(thoughts))) return { root, hasThoughts: false, tasks: [], readAt: Date.now() }
73 const entries = await $.fs.list(thoughts)
74 const tasks: Task[] = []
75 for (const entry of entries) {
76 if (entry.kind !== 'dir') continue
77 const dir = `${thoughts}/${entry.name}`
78 const phases: PhaseState[] = []
79 for (const [letter, name, file] of PHASES) {
80 phases.push(phaseState(letter, name, await readOrNull($, `${dir}/${file}`)))
81 }
82 if (phases.every(p => p.state === 'absent')) continue
83 tasks.push({ dir: entry.name, phases, steps: stepCounts(await readOrNull($, `${dir}/99-progress.md`)) })
84 }
85 tasks.sort((a, b) => a.dir.localeCompare(b.dir))
86 return { root, hasThoughts: true, tasks, readAt: Date.now() }
87}
88
89// A scan that fails leaves the last snapshot in place; the pane says when it was read.
90async function scanOrNull($: any): Promise<Snapshot | null> {
91 try {
92 return await scan($)
93 } catch {
94 return null
95 }
96}
97
98const short = (s: string, n: number) => (s.length > n ? `${s.slice(0, n - 1)}…` : s)
99
100export const register: Register = on => {
101 let stop: (() => void) | undefined
102
103 on('session.start', async ($, e, next) => {
104 await $.command.register({
105 name: 'qrspi-status',
106 description: 'Show each QRSPI task in thoughts/ — its phase, what blocks it, the next command. Optional: a project path',
107 })
108 const first = await scanOrNull($)
109 if (first) await $.state.set(SNAPSHOT, first)
110 stop?.()
111 stop = $.clock.every(10_000, async () => {
112 const s = await scanOrNull($)
113 if (s) await $.state.set(SNAPSHOT, s)
114 })
115 return next(e)
116 })
117
118 on('command.run', { command: 'qrspi-status' }, async ($, e) => {
119 const arg = e.args.trim()
120 if (arg) {
121 const base: string = await $.session.root()
122 const path = arg.startsWith('/') ? arg : `${base}/${arg}`
123 if (!(await $.fs.exists(path))) return { text: `qrspi-status: no such directory: ${arg}` }
124 await $.state.set(ROOT, path)
125 } else {
126 await $.state.set(ROOT, '')
127 }
128 const s = await scanOrNull($)
129 if (s) await $.state.set(SNAPSHOT, s)
130 await $.ui.open({ id: PANE, title: 'QRSPI' })
131 const { value: { tasks, hasThoughts } = EMPTY } = await $.state.get(SNAPSHOT)
132 return { text: hasThoughts ? `QRSPI pane opened — ${tasks.length} task(s) in thoughts/.` : 'QRSPI pane opened — no thoughts/ in this project.' }
133 })
134
135 on('turn.complete', async ($, e, next) => {
136 const ran = await next(e)
137 const s = await scanOrNull($)
138 if (s) await $.state.set(SNAPSHOT, s)
139 return ran
140 })
141
142 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
143 const { Box, Text } = $.ui.resolve(e)
144 const { value: snap = EMPTY } = await $.state.get(SNAPSHOT)
145 const width = Math.max(24, (e.props?.bodyColumns ?? 60) - 2)
146
147 if (!snap.hasThoughts) {
148 return (
149 <Box flexDirection="column">
150 <Text dimColor>No thoughts/ directory in {short(snap.root, width)}.</Text>
151 <Text dimColor>Start a task with /qrspi:new <TASK-ID> <ticket>.</Text>
152 </Box>
153 )
154 }
155
156 return (
157 <Box flexDirection="column">
158 <Text dimColor>{short(snap.root, width)}</Text>
159 {snap.tasks.length === 0 && <Text dimColor>thoughts/ holds no QRSPI task yet.</Text>}
160 {snap.tasks.map(task => {
161 const current = task.phases.find(p => p.state !== 'done')
162 const implementing = current === undefined
163 const finished = implementing && task.steps.total > 0 && task.steps.done === task.steps.total
164 return (
165 <Box flexDirection="column" marginBottom={1}>
166 <Text bold>{short(task.dir, width)}</Text>
167 <Text>
168 {task.phases.map(p => (
169 <Text
170 color={p.state === 'done' ? 'green' : p === current ? 'yellow' : undefined}
171 dimColor={p.state !== 'done' && p !== current}
172 bold={p === current}
173 >
174 {p.letter}{' '}
175 </Text>
176 ))}
177 <Text color={finished ? 'green' : implementing ? 'yellow' : undefined} dimColor={!implementing} bold={implementing && !finished}>
178 I {task.steps.total > 0 ? `${task.steps.done}/${task.steps.total}` : ''}
179 </Text>
180 </Text>
181 {current && current.state === 'absent' && <Text dimColor>{current.name}: not started</Text>}
182 {current && current.state === 'open' && (
183 <Box flexDirection="column">
184 <Text>{current.name} waits on:</Text>
185 {current.open.slice(0, 3).map(label => (
186 <Text dimColor> {short(label, width - 2)}</Text>
187 ))}
188 {current.open.length > 3 && <Text dimColor> and {current.open.length - 3} more</Text>}
189 </Box>
190 )}
191 {implementing && !finished && <Text dimColor>Implement: {task.steps.done} of {task.steps.total} steps done</Text>}
192 {finished ? (
193 <Text color="green">every step done</Text>
194 ) : (
195 <Text dimColor>{short(`/qrspi:next thoughts/${task.dir}`, width)}</Text>
196 )}
197 </Box>
198 )
199 })}
200 <Text dimColor>read {snap.readAt ? new Date(snap.readAt).toISOString().slice(11, 19) : '—'} UTC · refreshes every 10 s and after each turn</Text>
201 </Box>
202 )
203 })
204}
205types/index.d.ts 28 lines1// One artifact phase as read off its file: absent, every Status box ticked and no
2// placeholder left (done), or still open, with the Status lines that are not ticked.
3export type PhaseState = {
4 letter: string
5 name: string
6 state: 'absent' | 'done' | 'open'
7 open: string[]
8}
9
10export type Task = {
11 dir: string
12 phases: PhaseState[]
13 steps: { done: number; total: number }
14}
15
16export type Snapshot = {
17 root: string
18 hasThoughts: boolean
19 tasks: Task[]
20 readAt: number
21}
22
23declare module 'claude-code' {
24 interface PluginState {
25 'qrspi-pane': { snapshot: Snapshot; root: string }
26 }
27}
28