Pane with the task progress of each open OpenSpec change, read locally from tasks.md

<img src="assets/icon.png" width="96" height="96" alt="kolen-sdd-progress icon">
Every open OpenSpec change in a pane beside the conversation: per-phase progress, next task, missing artifacts, open questions and gate blockers, read straight from the change files.
Install • How It Reads OpenSpec • What It Shows • Options • Privacy
[!TIP] Counts you can trust: tasks are read with the same rules as the OpenSpec CLI, so the pane always agrees with
openspec list, custom schemas andapply.tracksincluded.
/plugin install kolen-sdd-progress --marketplace nelomr/claude-mods
Or from a terminal:
claude plugin marketplace add nelomr/claude-mods
claude plugin install kolen-sdd-progress@kolen-plugins
Requires Claude Code 2.1.275 or later.
It follows the rules of the OpenSpec CLI (checked against 1.14.x), so its counts match openspec list:
| Rule | Behaviour |
|---|---|
| Root | The nearest folder at or above the working directory that holds openspec/changes or openspec/specs |
| Changes | Every folder in openspec/changes/ except archive and dot folders; a folder that only wraps nested changes is reported, not read |
| Schema | schema of the change's .openspec.yaml, else of openspec/config.yaml, else spec-driven |
| Schema file | openspec/schemas/<name>/schema.yaml, then the user schemas folder set in the userSchemasDir option (skipped when empty), then node_modules/@fission-ai/openspec/schemas |
| Task files | The artifact whose generates equals apply.tracks (else the artifact with id tasks); globs such as plan/**/*.md are supported, dot files excluded |
| Task lines | Any list marker (-, *, +, 1., 1)) with a one-character checkbox; only x/X is done, so [ ], [~] and [] count as pending |
| Artifacts | Every artifact of the schema, present when its generates matches a file |
If a schema cannot be found, the change falls back to a top-level tasks.md, as the CLI does, and the card shows a warning.
One card per change:
## phase of the task files (with several task files, phases are prefixed by the file).O1., Q2:), only numbered items count.These are not part of OpenSpec. Each has a default and can be changed or disabled in the plugin options (/config):
| Option | Default | Effect | |||
|---|---|---|---|---|---|
operatorPattern | `runbook\ | \(operator\b\ | operator-only tasks\ | agents never run` | Phases whose heading matches count as operator work, split from agent work |
gatePattern | `mandatory gate\ | ^0\.\s*gates?\b` | A matching phase with pending tasks that names another open change marks the card as blocked by it | ||
unverifiedMarker | UNVERIFIED | Occurrences counted in the design artifact; empty disables it | |||
progressFile | apply-progress.md | Its last ## heading is shown as the last activity; empty disables it | |||
userSchemasDir | empty | Absolute path of your OpenSpec user schemas folder, usually ~/.local/share/openspec/schemas expanded (%LOCALAPPDATA%\openspec\schemas on Windows); empty skips user-level schemas |
Patterns are case-insensitive regular expressions; an empty or invalid one uses the default.
The pane opens when a session starts. Close it with the pane's close mark; reopen it with:
/sdd
It refreshes after every Edit, Write, MultiEdit, NotebookEdit and Bash tool call.
The mod registers four hooks and one command. This is everything it does:
| Hook | When it runs | What it does |
|---|---|---|
session.start | Once per session | Registers the /sdd command, reads the OpenSpec changes and opens the pane |
command.run (/sdd) | When you type /sdd | Matches only the mod's own /sdd command, so it never sees or changes any other command. It reads the changes again, reopens the pane and replies with one line: SDD pane opened. |
tool.call | After every tool call | Lets the call run unchanged; after Edit, Write, MultiEdit, NotebookEdit or Bash it reads the changes again and shows a toast for newly ticked tasks. It never blocks, rewrites or delays a call, and a failed read leaves the call as it was |
ui.render (pane) | When the pane is drawn | Draws the cards from the session state; the pending-tasks button only toggles which card is expanded |
Reading the changes means listing openspec/changes, reading the task, design and progress files of each change and the schema files, and stating the task files for their modification time.
prefers-reduced-motion./sdd.--store, a global defaultStore) are not followed; only the nearest root is read.artifacts (id, generates) and apply.tracks keys in block YAML; flow-style YAML for those keys is not read.hooks/register.tsx 450 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3
4import type { ArtifactState, ChangeProgress, Phase, ScanResult } from '../types'
5import type { Conventions, SchemaInfo, TaskFile } from './parse'
6import {
7 age,
8 buildProgress,
9 DEFAULT_CONVENTIONS,
10 matchOutputs,
11 newlyDone,
12 parseSchema,
13 patternOption,
14 SPEC_DRIVEN,
15 trackedGlob,
16 yamlScalar,
17} from './parse'
18
19const PANE = 'kolen-sdd-progress'
20const EMPTY: ScanResult = { root: undefined, changes: [], notices: [] }
21const scanState = atom({ plugin: 'kolen-sdd-progress', key: 'scan' } as const, EMPTY)
22const scannedAt = atom({ plugin: 'kolen-sdd-progress', key: 'scannedAt' } as const, 0)
23const expanded = atom({ plugin: 'kolen-sdd-progress', key: 'expanded' } as const, '')
24const WRITERS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit', 'Bash'])
25
26const CARD_W = 520
27// Approximate advance of the desktop code font in CSS px; bodyColumns is in those cells.
28const CELL_PX = 7.8
29const PAD = 16
30const PHASE_ROW = 18
31const MAX_PENDING = 8
32// Bounds of one change folder listing: nesting depth and entries read.
33const MAX_DEPTH = 8
34const MAX_FILES = 2000
35const PACKAGE_SCHEMAS = 'node_modules/@fission-ai/openspec/schemas'
36
37type Settings = { conventions: Conventions; progressFile: string; userSchemasDir: string }
38
39function settingsFrom(options: PluginOptions): Settings {
40 const marker = options.unverifiedMarker
41 const progress = options.progressFile
42 return {
43 conventions: {
44 operator: patternOption(options.operatorPattern, DEFAULT_CONVENTIONS.operator),
45 gate: patternOption(options.gatePattern, DEFAULT_CONVENTIONS.gate),
46 unverified: typeof marker === 'string' ? marker.trim() : DEFAULT_CONVENTIONS.unverified,
47 },
48 progressFile: typeof progress === 'string' ? progress.trim() : 'apply-progress.md',
49 userSchemasDir: typeof options.userSchemasDir === 'string' ? options.userSchemasDir.trim().replace(/[\\/]+$/, '') : '',
50 }
51}
52
53function ratioOf(done: number, total: number): number {
54 return total === 0 ? 0 : done / total
55}
56
57function esc(text: string): string {
58 return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
59}
60
61function clip(text: string, max: number): string {
62 return text.length > max ? `${text.slice(0, max - 1)}…` : text
63}
64
65function phaseLabel(phase: Phase): string {
66 return phase.title.replace(/\s*\((?:about|cannot)[^)]*\)\s*$/i, '')
67}
68
69function cardHeight(change: ChangeProgress): number {
70 return 84 + change.phases.length * PHASE_ROW + 30
71}
72
73function parentOf(dir: string): string {
74 const cut = dir.replace(/[\\/]+$/, '').replace(/[\\/][^\\/]*$/, '')
75 return cut === '' ? '/' : cut
76}
77
78// Card drawn by the surface itself: no hook timers, no model calls. Contrast on
79// its own background: text >= 13:1, muted >= 7:1, accent, done and warn >= 4.5:1
80// (WCAG AA), light and dark. One motion only: the bars ease in on redraw,
81// skipped under prefers-reduced-motion.
82function card(change: ChangeProgress, pxW: number, marker: string): string {
83 const height = cardHeight(change)
84 const ratio = ratioOf(change.done, change.total)
85 const barW = CARD_W - PAD * 2
86 const isDone = change.total > 0 && ratio >= 1
87 const pct = Math.round(ratio * 100)
88 const bar = (x: number, y: number, w: number, r: number, cls: string, thick: number) => {
89 const fill = Math.max(r > 0 ? 3 : 0, Math.round(w * r))
90 return `<rect class="k" x="${x}" y="${y}" width="${w}" height="${thick}" rx="${thick / 2}"/>${
91 fill === 0
92 ? ''
93 : `<rect class="${cls}" x="${x}" y="${y}" width="${fill}" height="${thick}" rx="${thick / 2}"><animate attributeName="width" from="0" to="${fill}" dur="0.6s" fill="freeze" calcMode="spline" keyTimes="0;1" keySplines="0.25 0.1 0.25 1"/></rect>`
94 }`
95 }
96
97 const miniW = 140
98 const barStart = CARD_W - PAD - miniW - 44
99 const rows = change.phases
100 .map((phase, index) => {
101 const y = 76 + index * PHASE_ROW
102 const r = ratioOf(phase.done, phase.total)
103 const isCurrent = phase.title === change.currentPhase
104 const titleCls = isCurrent ? 't' : 'm'
105 const weight = isCurrent ? ' font-weight="600"' : ''
106 const tag = phase.isOperator ? ' <tspan class="w">operator</tspan>' : ''
107 const cls = r >= 1 ? 'd' : phase.isOperator ? 'w' : 'a'
108 return `<text class="${titleCls}" x="${PAD}" y="${y + 9}" font-size="11"${weight} clip-path="url(#tc)">${esc(phaseLabel(phase))}${tag}</text>
109${bar(barStart, y + 3, miniW, r, cls, 5)}
110<text class="m" x="${CARD_W - PAD}" y="${y + 9}" font-size="11" text-anchor="end">${phase.done}/${phase.total}</text>`
111 })
112 .join('\n')
113
114 const mark = (artifact: ArtifactState) =>
115 `<tspan class="${artifact.isPresent ? 'm' : 'w'}">${artifact.isPresent ? '' : '✗ '}${esc(artifact.id)}</tspan>`
116 const footY = height - 18
117 const notes = [
118 change.schema !== 'spec-driven' ? `<tspan class="m">${esc(clip(change.schema, 20))}</tspan>` : '',
119 change.unverified > 0 ? `<tspan class="w">${change.unverified} ${esc(clip(marker, 14))}</tspan>` : '',
120 change.openQuestions.length > 0 ? `<tspan class="w">${change.openQuestions.length} open Q</tspan>` : '',
121 ]
122 .filter(Boolean)
123 .join('<tspan class="m"> · </tspan>')
124
125 return `<svg xmlns="http://www.w3.org/2000/svg" width="${pxW}" height="${Math.round((pxW * height) / CARD_W)}" viewBox="0 0 ${CARD_W} ${height}" preserveAspectRatio="xMinYMin meet">
126<defs><clipPath id="tc"><rect x="${PAD}" y="0" width="${barStart - PAD - 12}" height="${height}"/></clipPath><clipPath id="fc"><rect x="${PAD}" y="0" width="${CARD_W / 2 - PAD}" height="${height}"/></clipPath></defs>
127<style>
128.s{fill:#ffffff;stroke:#e4e4e7}.t{fill:#18181b}.m{fill:#52525b}.k{fill:#e4e4e7}.a{fill:#2563eb}.d{fill:#15803d}.w{fill:#b45309}.r{stroke:#e4e4e7}
129text{font-family:ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}
130@media (prefers-color-scheme:dark){.s{fill:#18181b;stroke:#27272a}.t{fill:#f4f4f5}.m{fill:#a1a1aa}.k{fill:#27272a}.a{fill:#60a5fa}.d{fill:#4ade80}.w{fill:#fbbf24}.r{stroke:#27272a}}
131@media (prefers-reduced-motion:reduce){animate{display:none}}
132</style>
133<rect class="s" x="0.5" y="0.5" width="${CARD_W - 1}" height="${height - 9}" rx="8"/>
134<text class="t" x="${PAD}" y="26" font-size="13" font-weight="600">${esc(clip(change.name, 30))}</text>
135<text class="m" x="${CARD_W - PAD}" y="26" font-size="12" text-anchor="end">${change.done} / ${change.total} · <tspan class="${isDone ? 'd' : 'a'}" font-weight="600">${pct}%</tspan></text>
136${bar(PAD, 36, barW, ratio, isDone ? 'd' : 'a', 6)}
137<text class="m" x="${PAD}" y="60" font-size="11">Agents ${change.agentPending} pending · <tspan class="w">You ${change.operatorPending}</tspan></text>
138<line class="r" x1="${PAD}" y1="68" x2="${CARD_W - PAD}" y2="68"/>
139${rows}
140<line class="r" x1="${PAD}" y1="${footY - 14}" x2="${CARD_W - PAD}" y2="${footY - 14}"/>
141<text x="${PAD}" y="${footY}" font-size="11" clip-path="url(#fc)">${change.artifacts.map(mark).join('<tspan class="m"> · </tspan>')}</text>
142<text x="${CARD_W - PAD}" y="${footY}" font-size="11" text-anchor="end">${notes}</text>
143</svg>`
144}
145
146async function exists($: EngineInterface, path: string): Promise<boolean> {
147 return $.fs.exists(path).catch(() => false)
148}
149
150async function readText($: EngineInterface, path: string): Promise<string | undefined> {
151 const text = await $.fs.read(path).catch(() => undefined)
152 return typeof text === 'string' ? text : undefined
153}
154
155// The nearest folder at or above the working directory holding openspec/changes or openspec/specs,
156// as OpenSpec resolves its "nearest" root.
157async function findRoot($: EngineInterface): Promise<string | undefined> {
158 let dir = await $.session.cwd()
159 for (let i = 0; i < 64; i += 1) {
160 if ((await exists($, `${dir}/openspec/changes`)) || (await exists($, `${dir}/openspec/specs`))) return dir
161 const parent = parentOf(dir)
162 if (parent === dir) return undefined
163 dir = parent
164 }
165 return undefined
166}
167
168async function walkFiles($: EngineInterface, base: string, rel: string, depth: number, files: string[]): Promise<void> {
169 if (depth > MAX_DEPTH || files.length >= MAX_FILES) return
170 const entries = await $.fs.list(rel === '' ? base : `${base}/${rel}`).catch(() => [])
171 for (const entry of entries) {
172 if (entry.name.startsWith('.') || files.length >= MAX_FILES) continue
173 const path = rel === '' ? entry.name : `${rel}/${entry.name}`
174 if (entry.kind === 'file') files.push(path)
175 else if (entry.kind === 'dir') await walkFiles($, base, path, depth + 1, files)
176 }
177}
178
179// Posix paths of the regular files under a change folder, dot entries skipped like OpenSpec's globs.
180async function listFiles($: EngineInterface, base: string): Promise<string[]> {
181 const files: string[] = []
182 await walkFiles($, base, '', 0, files)
183 return files
184}
185
186// Project schema, then user schema, then the package built-in, as OpenSpec's resolver orders them.
187// The user schemas folder comes from the plugin options, never from the environment.
188async function resolveSchema(
189 $: EngineInterface,
190 root: string,
191 name: string,
192 userSchemasDir: string,
193 cache: Map<string, SchemaInfo | undefined>,
194): Promise<SchemaInfo | undefined> {
195 if (cache.has(name)) return cache.get(name)
196 const candidates = [
197 `${root}/openspec/schemas/${name}/schema.yaml`,
198 ...(userSchemasDir ? [`${userSchemasDir}/${name}/schema.yaml`] : []),
199 `${root}/${PACKAGE_SCHEMAS}/${name}/schema.yaml`,
200 ]
201 let schema: SchemaInfo | undefined
202 for (const path of candidates) {
203 const yaml = await readText($, path)
204 if (yaml === undefined) continue
205 schema = parseSchema(name, yaml)
206 break
207 }
208 if (schema === undefined && name === SPEC_DRIVEN.name) schema = SPEC_DRIVEN
209 cache.set(name, schema)
210 return schema
211}
212
213async function scan($: EngineInterface, settings: Settings): Promise<ScanResult> {
214 const root = await findRoot($)
215 if (root === undefined) return EMPTY
216 const dir = `${root}/openspec/changes`
217 const config = await readText($, `${root}/openspec/config.yaml`)
218 const projectSchema = config ? yamlScalar(config, 'schema') : undefined
219 const names = (await $.fs.list(dir).catch(() => []))
220 .filter(entry => entry.kind === 'dir' && entry.name !== 'archive' && !entry.name.startsWith('.'))
221 .map(entry => entry.name)
222 const cache = new Map<string, SchemaInfo | undefined>()
223 const notices: string[] = []
224 const found: ChangeProgress[] = []
225 for (const name of names) {
226 const base = `${dir}/${name}`
227 const files = await listFiles($, base)
228 const metadata = await readText($, `${base}/.openspec.yaml`)
229 if (metadata === undefined && files.length > 0 && files.every(file => file.includes('/'))) {
230 const nested = await exists($, `${base}/${files[0]?.split('/')[0]}/.openspec.yaml`)
231 if (nested) {
232 notices.push(`${name} is not a change: it wraps nested changes`)
233 continue
234 }
235 }
236 const warnings: string[] = []
237 const schemaName = (metadata ? yamlScalar(metadata, 'schema') : undefined) ?? projectSchema ?? SPEC_DRIVEN.name
238 const schema = await resolveSchema($, root, schemaName, settings.userSchemasDir, cache)
239 let taskPaths: string[]
240 if (schema === undefined) {
241 warnings.push(`Schema "${schemaName}" not found; reading tasks.md`)
242 taskPaths = files.includes('tasks.md') ? ['tasks.md'] : []
243 } else {
244 const glob = trackedGlob(schema)
245 if (glob === undefined) warnings.push(`Schema "${schemaName}" tracks no tasks artifact`)
246 taskPaths = glob === undefined ? [] : matchOutputs(glob, files)
247 }
248 const taskFiles: TaskFile[] = []
249 let tasksMtimeMs = 0
250 for (const path of taskPaths) {
251 const text = await readText($, `${base}/${path}`)
252 if (text === undefined) continue
253 taskFiles.push({ path, text })
254 const stat = await $.fs.stat(`${base}/${path}`).catch(() => undefined)
255 tasksMtimeMs = Math.max(tasksMtimeMs, stat?.mtimeMs ?? 0)
256 }
257 const artifacts = (schema?.artifacts ?? []).map(artifact => ({
258 id: artifact.id,
259 isPresent: matchOutputs(artifact.generates, files).length > 0,
260 }))
261 const designGlob = schema?.artifacts.find(artifact => artifact.id === 'design')?.generates ?? 'design.md'
262 const designPath = matchOutputs(designGlob, files)[0]
263 const design = designPath ? await readText($, `${base}/${designPath}`) : undefined
264 const progress = settings.progressFile ? await readText($, `${base}/${settings.progressFile}`) : undefined
265 found.push(
266 buildProgress({
267 name,
268 schema: schemaName,
269 taskFiles,
270 design,
271 progress,
272 tasksMtimeMs,
273 artifacts,
274 openChanges: names,
275 warnings,
276 conventions: settings.conventions,
277 }),
278 )
279 }
280 return { root, changes: found.sort((a, b) => a.name.localeCompare(b.name)), notices }
281}
282
283async function toggleExpanded($: EngineInterface, name: string): Promise<void> {
284 await update($, expanded, value => (value === name ? '' : name))
285}
286
287async function refresh($: EngineInterface, settings: Settings, shouldAnnounce: boolean): Promise<void> {
288 const result = await scan($, settings).catch(() => undefined)
289 if (result === undefined) return
290 let ticked: string[] = []
291 await update($, scanState, before => {
292 ticked = before.root === result.root && before.changes.length > 0 ? newlyDone(before.changes, result.changes) : []
293 return result
294 })
295 await update($, scannedAt, () => Date.now())
296 if (shouldAnnounce && ticked.length > 0) {
297 const shown = ticked.slice(0, 3).join(', ')
298 const more = ticked.length > 3 ? ` and ${ticked.length - 3} more` : ''
299 void $.ui.toast(`✓ Done: ${shown}${more}`)
300 }
301}
302
303export const register: Register = (on, options) => {
304 const settings = settingsFrom(options)
305
306 on('session.start', async ($, e, next) => {
307 await $.command.register({
308 name: 'sdd',
309 description: 'Show the OpenSpec task progress pane',
310 })
311 await refresh($, settings, false)
312 void $.ui.open({ id: PANE, title: 'SDD' })
313
314 return next(e)
315 })
316
317 on('command.run', { command: 'sdd' }, async $ => {
318 await refresh($, settings, false)
319 const opened = await $.ui.open({ id: PANE, title: 'SDD' })
320
321 return { text: opened.isPlaced ? 'SDD pane opened.' : `SDD pane not placed: ${opened.reason}` }
322 })
323
324 // Observes only: a failed refresh never blocks or changes the tool call.
325 on('tool.call', async ($, e, next) => {
326 const ran = await next(e)
327 if (WRITERS.has(e.tool)) await refresh($, settings, true)
328
329 return ran
330 }).catch(($, e, next) => next(e))
331
332 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
333 const ui = $.ui.resolve(e)
334 const { Box, Text, Button } = ui
335 const result = await read($, scanState)
336 const now = await read($, scannedAt)
337 const open = await read($, expanded)
338 const hasSvg = e.surface !== 'terminal' && 'Svg' in ui
339 const width = Math.max(20, (e.props.bodyColumns ?? 48) - 2)
340 const pxW = Math.max(300, Math.round(width * CELL_PX))
341 const marker = settings.conventions.unverified
342
343 if (result.root === undefined) return <Text dimColor>No OpenSpec project found from the working directory.</Text>
344
345 return (
346 <Box flexDirection="column">
347 {result.notices.map(notice => (
348 <Text key={notice} color="warning">
349 ⚠ {clip(notice, width - 2)}
350 </Text>
351 ))}
352 {result.changes.length === 0 && <Text dimColor>No open OpenSpec changes.</Text>}
353 {result.changes.map(change => {
354 const isOpen = open === change.name
355 const current = change.phases.find(phase => phase.title === change.currentPhase)
356 const pending = current?.pending ?? []
357 const ratio = ratioOf(change.done, change.total)
358 const isDone = change.total > 0 && ratio >= 1
359 const barWidth = Math.max(5, Math.min(30, width - 16))
360 const filled = Math.round(ratio * barWidth)
361 const missing = change.artifacts.filter(artifact => !artifact.isPresent).map(artifact => artifact.id)
362
363 return (
364 <Box key={change.name} flexDirection="column" marginBottom={1}>
365 {hasSvg && 'Svg' in ui ? (
366 <ui.Svg
367 source={card(change, pxW, marker)}
368 width={pxW}
369 height={Math.round((pxW * cardHeight(change)) / CARD_W)}
370 alt={`${change.name}: ${change.done} of ${change.total} tasks done`}
371 isInteractive
372 />
373 ) : (
374 <Box flexDirection="column">
375 <Text bold>{change.name}</Text>
376 <Text>
377 <Text color={isDone ? 'success' : 'suggestion'}>{'█'.repeat(filled)}</Text>
378 <Text color="subtle">{'░'.repeat(barWidth - filled)}</Text>
379 <Text color={isDone ? 'success' : 'suggestion'}> {Math.round(ratio * 100)}%</Text>
380 <Text dimColor>
381 {' '}
382 {change.done}/{change.total}
383 </Text>
384 </Text>
385 {change.phases.map(phase => (
386 <Text
387 key={phase.title}
388 color={phase.done === phase.total ? 'success' : phase.isOperator ? 'warning' : undefined}
389 dimColor={phase.title !== change.currentPhase && phase.done !== phase.total}
390 bold={phase.title === change.currentPhase}
391 >
392 {clip(phaseLabel(phase), width - 10)} {phase.done}/{phase.total}
393 </Text>
394 ))}
395 <Text dimColor>
396 Agents {change.agentPending} · You {change.operatorPending}
397 {change.unverified > 0 ? ` · ${change.unverified} ${marker}` : ''}
398 {change.openQuestions.length > 0 ? ` · ${change.openQuestions.length} open Q` : ''}
399 {missing.length > 0 ? ` · missing ${missing.join(', ')}` : ''}
400 {change.schema !== 'spec-driven' ? ` · ${change.schema}` : ''}
401 </Text>
402 </Box>
403 )}
404 {change.warnings.map(warning => (
405 <Text key={warning} color="warning">
406 ⚠ {clip(warning, width - 2)}
407 </Text>
408 ))}
409 {change.blockedBy.length > 0 && (
410 <Text color="warning">⏸ Blocked by {change.blockedBy.join(', ')} (gate pending)</Text>
411 )}
412 {change.next !== undefined && <Text color="suggestion">▸ {clip(change.next, width - 2)}</Text>}
413 <Text dimColor>
414 tasks {change.tasksMtimeMs > 0 && now > 0 ? age(change.tasksMtimeMs, now) : 'n/a'}
415 {settings.progressFile
416 ? change.lastProgress
417 ? ` · last: ${clip(change.lastProgress, width - 24)}`
418 : ` · no ${settings.progressFile}`
419 : ''}
420 </Text>
421 {pending.length > 0 && (
422 <Button
423 key={`toggle-${change.name}`}
424 plain
425 onPress={() => {
426 void toggleExpanded($, change.name)
427 }}
428 >
429 {isOpen ? '▾ Hide pending' : `▸ Pending in current phase (${pending.length})`}
430 </Button>
431 )}
432 {isOpen &&
433 pending.slice(0, MAX_PENDING).map(task => (
434 <Text key={`${change.name}-${task.id || task.text}`} dimColor>
435 {' '}☐ {clip(task.text, width - 4)}
436 </Text>
437 ))}
438 {isOpen && pending.length > MAX_PENDING && (
439 <Text dimColor>
440 {' '}… {pending.length - MAX_PENDING} more
441 </Text>
442 )}
443 </Box>
444 )
445 })}
446 </Box>
447 )
448 })
449}
450hooks/parse.ts 316 lines1import type { ArtifactState, ChangeProgress, Phase, Task } from '../types'
2
3// The task line OpenSpec itself counts (dist/utils/task-progress.js, 1.14.x): any
4// CommonMark list marker, a checkbox holding at most one marker, and only x/X done.
5const TASK_LINE = /^\s*(?:[-*+]|\d{1,9}[.)])\s*\[(?:\s*([^\]\s]?)\s*\](?![([])|\s+\])\s*(.*)/
6const TASK_ID = /^(\d+(?:\.\d+)*[a-z]?)\b/
7const PHASE = /^##\s+(.*)$/
8const BULLET = /^(?:[-*+]|\d{1,9}[.)])\s+(.*)$/
9const RESOLVED = /^(?:~~|\*\*)?\s*(?:resolved|answered|closed|done)\b/i
10
11export type Conventions = {
12 operator: RegExp
13 gate: RegExp
14 unverified: string
15}
16
17export const DEFAULT_CONVENTIONS: Conventions = {
18 operator: /runbook|\(operator\b|operator-only tasks|agents never run/i,
19 gate: /mandatory gate|^0\.\s*gates?\b/i,
20 unverified: 'UNVERIFIED',
21}
22
23export type SchemaInfo = {
24 name: string
25 artifacts: { id: string; generates: string }[]
26 tracks: string | undefined
27}
28
29// Built-in `spec-driven` schema of OpenSpec 1.14.x, used when its schema.yaml cannot be read.
30export const SPEC_DRIVEN: SchemaInfo = {
31 name: 'spec-driven',
32 artifacts: [
33 { id: 'proposal', generates: 'proposal.md' },
34 { id: 'specs', generates: 'specs/**/*.md' },
35 { id: 'design', generates: 'design.md' },
36 { id: 'tasks', generates: 'tasks.md' },
37 ],
38 tracks: 'tasks.md',
39}
40
41export type TaskFile = { path: string; text: string }
42
43export type Sources = {
44 name: string
45 schema: string
46 taskFiles: TaskFile[]
47 design: string | undefined
48 progress: string | undefined
49 tasksMtimeMs: number
50 artifacts: ArtifactState[]
51 openChanges: string[]
52 warnings: string[]
53 conventions: Conventions
54}
55
56function unquote(value: string): string {
57 const trimmed = value.replace(/\s+#.*$/, '').trim()
58 const quoted = /^(['"])(.*)\1$/.exec(trimmed)
59 return quoted ? (quoted[2] ?? '') : trimmed
60}
61
62// Top-level `field: value` of a YAML file (config.yaml, .openspec.yaml).
63export function yamlScalar(yaml: string, field: string): string | undefined {
64 for (const line of yaml.split(/\r?\n/)) {
65 const match = new RegExp(`^${field}:\\s*(.+)$`).exec(line)
66 if (match) return unquote(match[1] ?? '') || undefined
67 }
68 return undefined
69}
70
71// The two parts of an OpenSpec schema.yaml the pane needs: artifacts (id, generates) and apply.tracks.
72export function parseSchema(name: string, yaml: string): SchemaInfo {
73 const artifacts: { id: string; generates: string }[] = []
74 let tracks: string | undefined
75 let block = ''
76 let current: { id?: string; generates?: string } | undefined
77 const flush = () => {
78 if (current?.id && current.generates) artifacts.push({ id: current.id, generates: current.generates })
79 current = undefined
80 }
81 for (const line of yaml.split(/\r?\n/)) {
82 const top = /^([A-Za-z_][\w-]*):/.exec(line)
83 if (top) {
84 flush()
85 block = top[1] ?? ''
86 continue
87 }
88 if (block === 'artifacts') {
89 const item = /^\s*-\s+id:\s*(.+)$/.exec(line)
90 if (item) {
91 flush()
92 current = { id: unquote(item[1] ?? '') }
93 continue
94 }
95 const generates = /^\s+generates:\s*(.+)$/.exec(line)
96 if (generates && current) current.generates = unquote(generates[1] ?? '')
97 }
98 if (block === 'apply') {
99 const tracked = /^\s+tracks:\s*(.+)$/.exec(line)
100 if (tracked) tracks = unquote(tracked[1] ?? '') || undefined
101 }
102 }
103 flush()
104 return { name, artifacts, tracks }
105}
106
107// The artifact apply works through: generates equal to apply.tracks, else id `tasks` (OpenSpec's rule).
108export function trackedGlob(schema: SchemaInfo): string | undefined {
109 if (schema.tracks !== undefined) return schema.artifacts.find(a => a.generates === schema.tracks)?.generates
110 return schema.artifacts.find(a => a.id === 'tasks')?.generates
111}
112
113export function isGlob(pattern: string): boolean {
114 return /[*?{[]/.test(pattern)
115}
116
117function expandBraces(pattern: string): string[] {
118 const match = /\{([^{}]*)\}/.exec(pattern)
119 if (!match) return [pattern]
120 const head = pattern.slice(0, match.index)
121 const tail = pattern.slice(match.index + match[0].length)
122 return (match[1] ?? '').split(',').flatMap(part => expandBraces(`${head}${part}${tail}`))
123}
124
125// Glob to RegExp over posix paths relative to the change folder: **, *, ?, {a,b}; dotfiles excluded.
126export function globMatcher(pattern: string): (path: string) => boolean {
127 const regexes = expandBraces(pattern.replace(/^\.\//, '').replace(/\/+/g, '/')).map(one => {
128 let source = ''
129 for (let i = 0; i < one.length; i += 1) {
130 const char = one[i] ?? ''
131 if (char === '*' && one[i + 1] === '*') {
132 const slash = one[i + 2] === '/'
133 source += slash ? '(?:[^/.][^/]*/)*' : '(?:[^/.][^/]*(?:/[^/.][^/]*)*)?'
134 i += slash ? 2 : 1
135 } else if (char === '*') source += '(?:[^/.][^/]*)?'
136 else if (char === '?') source += '[^/]'
137 else source += char.replace(/[.+^$()|[\]\\]/g, '\\$&')
138 }
139 return new RegExp(`^${source}$`)
140 })
141 return path => regexes.some(regex => regex.test(path))
142}
143
144// Files of a change that match an artifact's `generates`, from one listing of the change folder.
145export function matchOutputs(generates: string, files: string[]): string[] {
146 if (!isGlob(generates)) {
147 const literal = generates.replace(/^\.\//, '')
148 return files.includes(literal) ? [literal] : []
149 }
150 const matches = globMatcher(generates)
151 return files.filter(matches).sort()
152}
153
154export function parsePhases(files: TaskFile[], conventions: Conventions): Phase[] {
155 const phases: Phase[] = []
156 const many = files.length > 1
157 for (const file of files) {
158 let current: Phase | undefined
159 for (const line of file.text.split(/\r?\n/)) {
160 const heading = PHASE.exec(line)
161 if (heading) {
162 const title = (heading[1] ?? '').trim()
163 current = {
164 title: many ? `${file.path} · ${title}` : title,
165 done: 0,
166 total: 0,
167 isOperator: conventions.operator.test(title),
168 isGate: conventions.gate.test(title),
169 pending: [],
170 }
171 phases.push(current)
172 continue
173 }
174 const match = TASK_LINE.exec(line)
175 if (!match) continue
176 if (!current) {
177 current = { title: many ? file.path : 'Tasks', done: 0, total: 0, isOperator: false, isGate: false, pending: [] }
178 phases.push(current)
179 }
180 const text = (match[2] ?? '').trim()
181 const task: Task = { id: TASK_ID.exec(text)?.[1] ?? '', text, isDone: (match[1] ?? '').toLowerCase() === 'x' }
182 current.total += 1
183 if (task.isDone) current.done += 1
184 else current.pending.push(task)
185 }
186 }
187 return phases.filter(phase => phase.total > 0)
188}
189
190// Identifiers of done tasks: the task id when it has one, else its text, so any layout can be diffed.
191export function doneTaskIds(files: TaskFile[]): string[] {
192 const ids: string[] = []
193 for (const file of files) {
194 for (const line of file.text.split(/\r?\n/)) {
195 const match = TASK_LINE.exec(line)
196 if (!match || (match[1] ?? '').toLowerCase() !== 'x') continue
197 const text = (match[2] ?? '').trim()
198 ids.push(TASK_ID.exec(text)?.[1] ?? text)
199 }
200 }
201 return ids
202}
203
204// Items under an "Open Questions" heading not marked resolved (the spec-driven design template).
205// When the section numbers its questions (`O1.`, `Q2:`), only numbered items count, so notes and
206// decision lists kept in the same section are not read as questions; otherwise every bullet counts.
207export function openQuestions(design: string): string[] {
208 const items: { id: string | undefined; rest: string }[] = []
209 let level = 0
210 for (const line of design.split(/\r?\n/)) {
211 const heading = /^(#{1,6})\s+(.*)$/.exec(line)
212 if (heading) {
213 const depth = (heading[1] ?? '').length
214 if (/open questions/i.test(heading[2] ?? '')) level = depth
215 else if (level > 0 && depth <= level) level = 0
216 continue
217 }
218 if (level === 0) continue
219 const item = BULLET.exec(line)
220 if (!item) continue
221 const body = (item[1] ?? '').trim()
222 const id = /^(?:\*\*)?([A-Z]+\d+)[.:)]/.exec(body)?.[1]
223 const rest = id ? body.replace(/^(?:\*\*)?[A-Z]+\d+[.:)](?:\*\*)?\s*/, '') : body
224 items.push({ id, rest })
225 }
226 const isNumbered = items.some(item => item.id !== undefined)
227 return items
228 .filter(item => !isNumbered || item.id !== undefined)
229 .filter(item => !/^none\b/i.test(item.rest) && !RESOLVED.test(item.rest))
230 .map(item => item.id ?? (item.rest.length > 40 ? `${item.rest.slice(0, 39)}…` : item.rest))
231}
232
233export function lastHeading(markdown: string): string | undefined {
234 const headings = markdown.split(/\r?\n/).filter(line => /^##\s/.test(line))
235 return headings.at(-1)?.replace(/^##\s+/, '').trim()
236}
237
238// Another open change named in a gate phase that still has pending tasks.
239export function blockedBy(phases: Phase[], self: string, openChanges: string[]): string[] {
240 const blockers = new Set<string>()
241 for (const phase of phases) {
242 if (!phase.isGate || phase.pending.length === 0) continue
243 const text = [phase.title, ...phase.pending.map(task => task.text)].join('\n')
244 for (const other of openChanges) {
245 if (other !== self && text.includes(other)) blockers.add(other)
246 }
247 }
248 return [...blockers]
249}
250
251export function buildProgress(sources: Sources): ChangeProgress {
252 const phases = parsePhases(sources.taskFiles, sources.conventions)
253 let done = 0
254 let total = 0
255 let operatorPending = 0
256 let agentPending = 0
257 for (const phase of phases) {
258 done += phase.done
259 total += phase.total
260 if (phase.isOperator) operatorPending += phase.pending.length
261 else agentPending += phase.pending.length
262 }
263 const current = phases.find(phase => !phase.isOperator && phase.pending.length > 0)
264 const marker = sources.conventions.unverified
265 return {
266 name: sources.name,
267 schema: sources.schema,
268 done,
269 total,
270 operatorPending,
271 agentPending,
272 phases,
273 currentPhase: current?.title,
274 next: current?.pending[0]?.text,
275 blockedBy: blockedBy(phases, sources.name, sources.openChanges),
276 lastProgress: sources.progress ? lastHeading(sources.progress) : undefined,
277 tasksMtimeMs: sources.tasksMtimeMs,
278 artifacts: sources.artifacts,
279 unverified: marker && sources.design ? sources.design.split(marker).length - 1 : 0,
280 openQuestions: sources.design ? openQuestions(sources.design) : [],
281 doneIds: doneTaskIds(sources.taskFiles),
282 warnings: sources.warnings,
283 }
284}
285
286export function newlyDone(before: ChangeProgress[], after: ChangeProgress[]): string[] {
287 const known = new Map(before.map(change => [change.name, new Set(change.doneIds)]))
288 const lines: string[] = []
289 for (const change of after) {
290 const seen = known.get(change.name)
291 if (!seen) continue
292 for (const id of change.doneIds) {
293 if (!seen.has(id)) lines.push(`${change.name} ${id.length > 40 ? `${id.slice(0, 39)}…` : id}`)
294 }
295 }
296 return lines
297}
298
299export function age(fromMs: number, nowMs: number): string {
300 const minutes = Math.max(0, Math.round((nowMs - fromMs) / 60000))
301 if (minutes < 60) return `${minutes}m ago`
302 const hours = Math.round(minutes / 60)
303 if (hours < 48) return `${hours}h ago`
304 return `${Math.round(hours / 24)}d ago`
305}
306
307// A user pattern from the plugin options, or the default when it is empty or not a valid RegExp.
308export function patternOption(value: unknown, fallback: RegExp): RegExp {
309 if (typeof value !== 'string' || value.trim() === '') return fallback
310 try {
311 return new RegExp(value, 'i')
312 } catch {
313 return fallback
314 }
315}
316types/index.d.ts 45 lines1export type Task = { id: string; text: string; isDone: boolean }
2
3export type Phase = {
4 title: string
5 done: number
6 total: number
7 isOperator: boolean
8 isGate: boolean
9 pending: Task[]
10}
11
12export type ArtifactState = { id: string; isPresent: boolean }
13
14export type ChangeProgress = {
15 name: string
16 schema: string
17 done: number
18 total: number
19 operatorPending: number
20 agentPending: number
21 phases: Phase[]
22 currentPhase: string | undefined
23 next: string | undefined
24 blockedBy: string[]
25 lastProgress: string | undefined
26 tasksMtimeMs: number
27 artifacts: ArtifactState[]
28 unverified: number
29 openQuestions: string[]
30 doneIds: string[]
31 warnings: string[]
32}
33
34export type ScanResult = {
35 root: string | undefined
36 changes: ChangeProgress[]
37 notices: string[]
38}
39
40declare module 'claude-code' {
41 interface PluginState {
42 'kolen-sdd-progress': { scan: ScanResult; scannedAt: number; expanded: string }
43 }
44}
45