SLOPSHOPPER

kolen-sdd-progress

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

newpaneguardcommandtoast
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · kolen-sdd-progress
│ ┃ SDD ✕ › fix the failing auth test and add an audit log call │ ┃ No OpenSpec project found from the working │ ┃ directory. ⏺ 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 │ │ › /sdd │ ⎿ kolen-sdd-progress: SDD pane opened. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · SDD
No OpenSpec project found from the working directory.
README

<img src="assets/icon.png" width="96" height="96" alt="kolen-sdd-progress icon">

kolen-sdd-progress

Live OpenSpec Progress Pane for Claude Code

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.

Version Claude Code OpenSpec Model calls License: MIT

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 and apply.tracks included.

Install

/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.

How it reads OpenSpec

It follows the rules of the OpenSpec CLI (checked against 1.14.x), so its counts match openspec list:

RuleBehaviour
RootThe nearest folder at or above the working directory that holds openspec/changes or openspec/specs
ChangesEvery folder in openspec/changes/ except archive and dot folders; a folder that only wraps nested changes is reported, not read
Schemaschema of the change's .openspec.yaml, else of openspec/config.yaml, else spec-driven
Schema fileopenspec/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 filesThe artifact whose generates equals apply.tracks (else the artifact with id tasks); globs such as plan/**/*.md are supported, dot files excluded
Task linesAny list marker (-, *, +, 1., 1)) with a one-character checkbox; only x/X is done, so [ ], [~] and [] count as pending
ArtifactsEvery 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.

What it shows

One card per change:

  • Overall progress, and progress per ## phase of the task files (with several task files, phases are prefixed by the file).
  • The current phase and its next task; a button lists its pending tasks.
  • The schema artifacts, with missing ones flagged.
  • Open questions from the design artifact: items under an "Open Questions" heading that are not marked resolved. When the section numbers its questions (O1., Q2:), only numbered items count.
  • The age of the newest task file.
  • A toast when tasks become ticked.

Optional conventions

These are not part of OpenSpec. Each has a default and can be changed or disabled in the plugin options (/config):

OptionDefaultEffect
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
unverifiedMarkerUNVERIFIEDOccurrences counted in the design artifact; empty disables it
progressFileapply-progress.mdIts last ## heading is shown as the last activity; empty disables it
userSchemasDiremptyAbsolute 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.

Usage

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.

Hooks

The mod registers four hooks and one command. This is everything it does:

HookWhen it runsWhat it does
session.startOnce per sessionRegisters the /sdd command, reads the OpenSpec changes and opens the pane
command.run (/sdd)When you type /sddMatches 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.callAfter every tool callLets 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 drawnDraws 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.

Surfaces

  • Desktop and editor: each change is an SVG card that follows the system light or dark theme, with text contrast at WCAG AA or better. The only motion is the progress bars easing in, and it is skipped under prefers-reduced-motion.
  • Terminal: the same information as text in the colors of the active Claude Code theme.

Privacy and cost

  • Reads local files only: the OpenSpec folder of the project and the schema files above. It reads no environment variables and no credentials. No network access, no file writes, no processes.
  • Makes no model calls and adds nothing to the system prompt or the conversation, apart from the one-line reply of /sdd.
  • Keeps its state in the session; nothing is stored across sessions.

Limitations

  • OpenSpec stores (--store, a global defaultStore) are not followed; only the nearest root is read.
  • The schema reader understands the artifacts (id, generates) and apply.tracks keys in block YAML; flow-style YAML for those keys is not read.
  • A change folder is read up to 8 levels deep and 2,000 files.
  • The desktop card width is derived from the pane width in code-font cells, with an approximate cell width of 7.8 CSS px.

License

MIT

Source 3 files
hooks/register.tsx 450 lines
1import { 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
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}
450
hooks/parse.ts 316 lines
1import 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}
316
types/index.d.ts 45 lines
1export 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