SLOPSHOPPER

openspec-tracker

Sigue el flujo de los cambios de OpenSpec: fase, artefactos, tareas y acciones /opsx en un panel.

newpaneguardcommandtoaststatus
v0.1.0no licenseupdated 2026-10-05Dos2Locos/claude-code-mods/plugins/openspec-tracker
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · openspec-tracker
│ ┃ OpenSpec ✕ › fix the failing auth test and add an audit log call │ ┃ 0 cambios activos r: Actualizar │ ┃ Nada en curso. Crea uno con /opsx:propose. ⏺ 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 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · OpenSpec
0 cambios activos r: Actualizar Nada en curso. Crea uno con /opsx:propose.
README

Mods de Claude Code · Dos2Locos

Marketplace de mods para Claude Code.

Instalar

claude plugin marketplace add Dos2Locos/claude-code-mods
claude plugin install clean-view@dos2locos-mods
claude plugin install openspec-tracker@dos2locos-mods
claude plugin install clean-bar@dos2locos-mods

En una sesión abierta, ejecuta /reload-plugins después de instalar o actualizar.

Para actualizar: claude plugin marketplace update dos2locos-mods y claude plugin update clean-view@dos2locos-mods.

Mods

clean-view

Vista limpia mientras Claude trabaja:

  • Oculta las filas de llamadas a herramientas (ctrl+o sigue mostrándolas).
  • Lista de pasos con su estado (○ pendiente, ◐ en curso, ✓ hecho, ✗ error) y barra de progreso sobre el prompt.
  • Progreso junto al spinner: Pensando · paso 3/5….
  • Resumen breve al terminar: archivos modificados, comandos y errores.
  • Se activa y desactiva con 0 (prompt vacío), el botón de la franja o /clean-view [on|off].

Funciona en la terminal y en la pestaña Code de la app de escritorio. Requiere Claude Code v2.1.287 o posterior.

clean-bar

Clean View y Context Bar en un único mod: una caja sobre el prompt, con dos secciones.

  • Oculta las filas de llamadas a herramientas (ctrl+o sigue mostrándolas).
  • Con plan (TodoWrite, Tasks o work_state_write de claude-mem): el prompt como cabecera en degradado, Paso n de m con barra y porcentaje, y una fila por tarea con su mini barra y estado (Hecho, En curso, Siguiente, Pendiente). Muestra hasta 6 tareas alrededor de la actual y, debajo, la acción en curso (↳ Editar app.ts).
  • Sin plan, muestra la acción actual y el número de pasos (no hay porcentaje que calcular).
  • Al terminar, resumen breve: archivos modificados, comandos y errores.
  • Sección de contexto (como context-bar): tokens usados de la ventana, umbral de autocompactado, porcentaje, barra apilada por categoría y leyenda. Se refresca al acabar cada turno y tras /compact.
  • Cada sección se pliega a una sola línea con 1 (progreso) y 2 (contexto) en el prompt vacío, o con su botón ▾/▸. Plegadas muestran la tarea actual con mini barra y porcentaje, y el uso de contexto con mini barra apilada y porcentaje. La elección se recuerda entre sesiones.
  • Se activa y desactiva con 0 (prompt vacío), el botón de la caja o /clean-bar [on|off].

Sustituye a clean-view y context-bar: conviene no tenerlos activos a la vez.

openspec-tracker

Seguimiento del flujo de OpenSpec en el proyecto de la sesión:

  • /openspec abre un panel con los cambios activos: fase (propuesta → specs → diseño → tareas → implementación → listo para archivar), barra de tareas y última modificación. Pulsa 1–9 para abrir uno.
  • Detalle de un cambio: artefactos (✓ hecho, ○ pendiente), tareas de tasks.md por sección y validación con openspec validate.
  • Botones a Aplicar, x Archivar y e Revisar: escriben /opsx:apply <id> (o equivalente) en el prompt sin enviarlo. v Validar y r Actualizar se ejecutan al momento.
  • Línea de estado con el cambio en curso, detectado por lo que Claude edita o ejecuta: ◐ OpenSpec 05-update-member-screens-ux · 7/15 · implementación.
  • Aviso cuando un cambio completa todas sus tareas.

Requiere la CLI openspec (v1.x) instalada. Solo se activa en proyectos con carpeta openspec/.

Source 2 files
hooks/register.tsx 433 lines
1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register, Timer } from 'claude-code'
3
4import type { Artifact, ChangeInfo, Detail, Snapshot, TaskSection, Validation, View } from '../types'
5
6const snapshot = atom({ plugin: 'openspec-tracker', key: 'snapshot' } as const, null)
7const current = atom({ plugin: 'openspec-tracker', key: 'current' } as const, null)
8const view = atom({ plugin: 'openspec-tracker', key: 'view' } as const, { kind: 'list' } as View)
9const detail = atom({ plugin: 'openspec-tracker', key: 'detail' } as const, null)
10
11const PANE = 'openspec'
12const REFRESH_DELAY_MS = 400
13/** Dónde buscar la CLI cuando no está en el PATH del proceso. */
14const OPENSPEC_BINS = ['openspec', '/opt/homebrew/bin/openspec', '/usr/local/bin/openspec']
15
16const PHASE_LABEL: Record<string, string> = {
17  proposal: 'propuesta',
18  specs: 'specs',
19  design: 'diseño',
20  tasks: 'tareas',
21  apply: 'implementación',
22  archive: 'listo para archivar',
23}
24
25const ARTIFACT_ICON: Record<string, string> = { done: '✓', ready: '○', blocked: '·' }
26
27// Temporizador del refresco agrupado; se pierde en una recarga, y no importa.
28let pendingRefresh: Timer | null = null
29
30// ── Lectura del estado ──────────────────────────────────────────────────────
31
32type ListJson = {
33  changes?: { name: string; completedTasks: number; totalTasks: number; lastModified: string }[]
34  root?: { path: string } | null
35}
36
37type StatusJson = {
38  changes?: { changeName: string; applyRequires?: string[]; artifacts?: Artifact[] }[]
39}
40
41async function runOpenspec($: EngineInterface, args: string[]): Promise<{ ok: boolean; json: unknown; text: string }> {
42  for (const bin of OPENSPEC_BINS) {
43    try {
44      const ran = await $.process.run([bin, ...args], { timeoutMs: 20_000 })
45      try {
46        return { ok: ran.exitCode === 0, json: JSON.parse(ran.stdout), text: ran.stdout }
47      } catch {
48        return { ok: false, json: null, text: ran.stderr || ran.stdout }
49      }
50    } catch {
51      // No se pudo arrancar con esta ruta: probamos la siguiente.
52    }
53  }
54  return { ok: false, json: null, text: 'No encuentro la CLI `openspec` (npm i -g @fission-ai/openspec).' }
55}
56
57/** La fase: el primer artefacto pendiente, o implementación / archivar cuando ya se puede aplicar. */
58export function phaseOf(artifacts: Artifact[], applyRequires: string[], completed: number, total: number): string {
59  const isDone = (id: string) => artifacts.find(a => a.id === id)?.status === 'done'
60  if (applyRequires.length > 0 && applyRequires.every(isDone)) {
61    return total > 0 && completed === total ? 'archive' : 'apply'
62  }
63  return artifacts.find(a => a.status !== 'done')?.id ?? 'apply'
64}
65
66async function loadSnapshot($: EngineInterface): Promise<Snapshot> {
67  const now = await $.clock.now()
68  const cwd = await $.session.cwd()
69  const list = await runOpenspec($, ['list', '--json'])
70  const listJson = list.json as ListJson | null
71  if (!listJson || !listJson.root) {
72    const error = listJson ? 'Este proyecto no usa OpenSpec (no hay carpeta openspec/).' : list.text.trim()
73    return { root: cwd, changes: [], error, refreshedAt: now }
74  }
75
76  const status = await runOpenspec($, ['status', '--all', '--json'])
77  const byName = new Map(((status.json as StatusJson | null)?.changes ?? []).map(c => [c.changeName, c]))
78
79  const changes: ChangeInfo[] = (listJson.changes ?? []).map(c => {
80    const s = byName.get(c.name)
81    const artifacts = (s?.artifacts ?? []).map(a => ({ id: a.id, status: a.status }))
82    return {
83      name: c.name,
84      completed: c.completedTasks,
85      total: c.totalTasks,
86      lastModified: c.lastModified,
87      artifacts,
88      phase: phaseOf(artifacts, s?.applyRequires ?? [], c.completedTasks, c.totalTasks),
89    }
90  })
91  return { root: listJson.root.path, changes, error: null, refreshedAt: now }
92}
93
94/** Las tareas de `tasks.md` agrupadas por sus encabezados `##`. */
95export function parseTasks(markdown: string): TaskSection[] {
96  const sections: TaskSection[] = []
97  let section: TaskSection | null = null
98  for (const line of markdown.split('\n')) {
99    const heading = /^#{2,4}\s+(.+?)\s*$/.exec(line)
100    if (heading?.[1]) {
101      section = { title: heading[1], tasks: [] }
102      sections.push(section)
103      continue
104    }
105    const task = /^\s*[-*]\s+\[([ xX])\]\s+(.+?)\s*$/.exec(line)
106    if (task?.[2]) {
107      if (!section) {
108        section = { title: 'Tareas', tasks: [] }
109        sections.push(section)
110      }
111      section.tasks.push({ text: task[2], isDone: task[1] !== ' ' })
112    }
113  }
114  return sections.filter(s => s.tasks.length > 0)
115}
116
117async function loadSections($: EngineInterface, root: string, name: string): Promise<TaskSection[]> {
118  const path = `${root}/openspec/changes/${name}/tasks.md`
119  if (!(await $.fs.exists(path))) return []
120  return parseTasks(String(await $.fs.read(path)))
121}
122
123async function refresh($: EngineInterface) {
124  const before = await read($, snapshot)
125  const next = await loadSnapshot($)
126  await update($, snapshot, () => next)
127
128  // Aviso cuando un cambio acaba de completar todas sus tareas.
129  for (const change of next.changes) {
130    const old = before?.changes.find(c => c.name === change.name)
131    if (old && old.phase !== 'archive' && change.phase === 'archive') {
132      $.ui.toast(`OpenSpec: «${change.name}» completó sus tareas, listo para archivar`)
133    }
134  }
135
136  const selected = await read($, view)
137  if (selected.kind === 'detail') {
138    const sections = await loadSections($, next.root, selected.name)
139    await update($, detail, d =>
140      d && d.name === selected.name ? { ...d, sections } : { name: selected.name, sections, validation: null, isValidating: false },
141    )
142  }
143  await showStatusLine($)
144}
145
146function scheduleRefresh($: EngineInterface) {
147  pendingRefresh?.cancel()
148  pendingRefresh = $.clock.after(REFRESH_DELAY_MS, () => {
149    pendingRefresh = null
150    void refresh($).catch(() => undefined)
151  })
152}
153
154// ── Cambio en curso y línea de estado ───────────────────────────────────────
155
156/** El cambio al que se refiere una ruta, un comando o un prompt, si nombra uno. */
157export function changeNamedIn(text: string): string | null {
158  const path = /openspec\/changes\/(?!archive\/)([^/\s"']+)\//.exec(text)
159  if (path?.[1]) return path[1]
160  const flag = /--change[= ]["']?([\w.-]+)/.exec(text)
161  if (flag?.[1]) return flag[1]
162  const command = /(?:\/opsx:\w+|openspec\s+(?:archive|validate|show))\s+["']?([\w.-]+)/.exec(text)
163  return command?.[1] && !command[1].startsWith('-') ? command[1] : null
164}
165
166async function setCurrent($: EngineInterface, name: string | null) {
167  if (!name || (await read($, current)) === name) return
168  await update($, current, () => name)
169  await showStatusLine($)
170}
171
172function progressBar(done: number, total: number, width: number): string {
173  const filled = total === 0 ? 0 : Math.round((done / total) * width)
174  return '█'.repeat(filled) + '░'.repeat(width - filled)
175}
176
177async function showStatusLine($: EngineInterface) {
178  const snap = await read($, snapshot)
179  if (!snap || snap.error) return $.ui.status(undefined)
180  const name = await read($, current)
181  const change = snap.changes.find(c => c.name === name)
182  if (change) {
183    const tasks = change.total > 0 ? ` · ${change.completed}/${change.total}` : ''
184    $.ui.status(`◐ OpenSpec ${change.name}${tasks} · ${PHASE_LABEL[change.phase] ?? change.phase}`)
185  } else {
186    const n = snap.changes.length
187    $.ui.status(n === 0 ? undefined : `OpenSpec · ${n} ${n === 1 ? 'cambio activo' : 'cambios activos'} · /openspec`)
188  }
189}
190
191// ── Acciones ────────────────────────────────────────────────────────────────
192
193async function hasOpsx($: EngineInterface, root: string): Promise<boolean> {
194  const home = await $.env.get('HOME')
195  const places = [`${root}/.claude/commands/opsx`, ...(home ? [`${home}/.claude/commands/opsx`] : [])]
196  for (const place of places) if (await $.fs.exists(place)) return true
197  return false
198}
199
200/** Escribe el comando en el prompt sin enviarlo: tú decides si lo lanzas. */
201async function prepare($: EngineInterface, action: 'apply' | 'archive' | 'explore', name: string) {
202  const snap = await read($, snapshot)
203  const opsx = await hasOpsx($, snap?.root ?? (await $.session.cwd()))
204  const fallback = {
205    apply: `Implementa las tareas del cambio de OpenSpec «${name}».`,
206    archive: `Archiva el cambio de OpenSpec «${name}» y actualiza las specs.`,
207    explore: `Revisa el cambio de OpenSpec «${name}» y dime qué falta.`,
208  }
209  const text = opsx ? `/opsx:${action} ${name}` : fallback[action]
210  const filled = await $.prompt.fill({ text })
211  if (!filled.isFilled) $.ui.toast(`Escribe: ${text}`)
212  await setCurrent($, name)
213}
214
215async function validate($: EngineInterface, name: string) {
216  await update($, detail, d => (d && d.name === name ? { ...d, isValidating: true } : d))
217  const ran = await runOpenspec($, ['validate', name, '--json'])
218  const item = (ran.json as { items?: { valid: boolean; issues?: unknown[] }[] } | null)?.items?.[0]
219  const validation: Validation = item
220    ? { isValid: item.valid, issues: (item.issues ?? []).map(issueText) }
221    : { isValid: false, issues: [ran.text.trim() || 'No se pudo validar.'] }
222  await update($, detail, d => (d && d.name === name ? { ...d, validation, isValidating: false } : d))
223}
224
225function issueText(issue: unknown): string {
226  if (typeof issue === 'string') return issue
227  const i = issue as { message?: string; path?: string; level?: string }
228  return [i.level, i.path, i.message].filter(Boolean).join(' · ') || JSON.stringify(issue)
229}
230
231async function openDetail($: EngineInterface, name: string) {
232  const snap = await read($, snapshot)
233  await update($, view, () => ({ kind: 'detail', name }) as View)
234  const sections = snap ? await loadSections($, snap.root, name) : []
235  await update($, detail, () => ({ name, sections, validation: null, isValidating: false }) as Detail)
236}
237
238async function backToList($: EngineInterface) {
239  await update($, view, () => ({ kind: 'list' }) as View)
240}
241
242function relativeTime(iso: string, now: number): string {
243  const minutes = Math.max(0, Math.round((now - Date.parse(iso)) / 60_000))
244  if (Number.isNaN(minutes)) return ''
245  if (minutes < 60) return `hace ${minutes} min`
246  const hours = Math.round(minutes / 60)
247  if (hours < 48) return `hace ${hours} h`
248  return `hace ${Math.round(hours / 24)} d`
249}
250
251// ── Registro ────────────────────────────────────────────────────────────────
252
253export const register: Register = on => {
254  on('session.start', async ($, e, next) => {
255    await $.command.register({
256      name: 'openspec',
257      description: 'Abre el panel de OpenSpec Tracker con los cambios activos del proyecto',
258      immediate: true,
259    })
260    // Sin esperar: la sesión arranca ya y el estado llega en cuanto la CLI responde.
261    void refresh($).catch(() => undefined)
262    return next(e)
263  })
264
265  on('command.run', { command: 'openspec' }, async $ => {
266    await refresh($)
267    await $.ui.open({ id: PANE, title: 'OpenSpec', focus: true })
268    return {}
269  })
270
271  // Detecta el cambio en curso por el prompt (/opsx:apply <id>, …).
272  on('prompt.submit', async ($, e, next) => {
273    if (/\/opsx:|openspec/i.test(e.text)) await setCurrent($, changeNamedIn(e.text))
274    return next(e)
275  })
276
277  // Detecta el cambio en curso y refresca cuando se toca OpenSpec.
278  on('tool.call', async ($, e, next) => {
279    const tool = String(e.tool)
280    const fields = e as unknown as Record<string, unknown>
281    const text = [fields.file_path, fields.command, fields.skill, fields.args]
282      .filter(v => typeof v === 'string')
283      .join(' ')
284    const touchesOpenspec = /openspec/i.test(text)
285    if (touchesOpenspec) await setCurrent($, changeNamedIn(text))
286
287    const ran = await next(e)
288    const writes = ['Edit', 'MultiEdit', 'Write', 'Bash', 'Skill'].includes(tool)
289    if (touchesOpenspec && writes) scheduleRefresh($)
290    return ran
291  })
292
293  on('turn.complete', async ($, e, next) => {
294    if (e.agentId === undefined) scheduleRefresh($)
295    return next(e)
296  })
297
298  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
299    const { Box, Text, Button } = $.ui.resolve(e)
300    const snap = await read($, snapshot)
301    const selected = await read($, view)
302    const width = Math.max(8, Math.min(20, e.props.bodyColumns - 30))
303
304    const refreshButton = (
305      <Button key="refresh" hotkey="r" plain label="Actualizar" onPress={() => refresh($)} />
306    )
307
308    if (!snap) return <Text dimColor>Leyendo OpenSpec…</Text>
309    if (snap.error) {
310      return (
311        <Box flexDirection="column">
312          <Text color="yellow">{snap.error}</Text>
313          {refreshButton}
314        </Box>
315      )
316    }
317
318    if (selected.kind === 'detail') {
319      const change = snap.changes.find(c => c.name === selected.name)
320      const d = await read($, detail)
321      const back = <Button key="back" hotkey="b" plain label="Volver" onPress={() => backToList($)} />
322      if (!change) {
323        return (
324          <Box flexDirection="column">
325            <Text dimColor>«{selected.name}» ya no está activo (¿archivado?).</Text>
326            {back}
327          </Box>
328        )
329      }
330      const sections = d?.name === change.name ? d.sections : []
331      return (
332        <Box flexDirection="column">
333          <Text bold wrap="truncate-end">{change.name}</Text>
334          <Text>
335            <Text dimColor>Fase: </Text>
336            <Text color="cyan">{PHASE_LABEL[change.phase] ?? change.phase}</Text>
337          </Text>
338          <Text wrap="wrap">
339            {change.artifacts.map(a => (
340              <Text color={a.status === 'done' ? 'green' : undefined} dimColor={a.status === 'blocked'}>
341                {ARTIFACT_ICON[a.status] ?? '○'} {a.id}{'  '}
342              </Text>
343            ))}
344          </Text>
345          {change.total > 0 && (
346            <Text>
347              <Text color={change.completed === change.total ? 'green' : 'yellow'}>
348                {progressBar(change.completed, change.total, width)}
349              </Text>
350              <Text dimColor> {change.completed}/{change.total} tareas</Text>
351            </Text>
352          )}
353          <Box columnGap={2} flexWrap="wrap">
354            <Button key="apply" hotkey="a" plain label="Aplicar" onPress={() => prepare($, 'apply', change.name)} />
355            <Button key="validate" hotkey="v" plain label="Validar" onPress={() => validate($, change.name)} />
356            <Button key="archive" hotkey="x" plain label="Archivar" onPress={() => prepare($, 'archive', change.name)} />
357            <Button key="explore" hotkey="e" plain label="Revisar" onPress={() => prepare($, 'explore', change.name)} />
358            {back}
359          </Box>
360          {d?.isValidating && <Text dimColor>Validando…</Text>}
361          {d?.validation && (
362            <Box flexDirection="column">
363              <Text color={d.validation.isValid ? 'green' : 'red'}>
364                {d.validation.isValid ? '✓ Validación correcta' : `✗ ${d.validation.issues.length} problemas`}
365              </Text>
366              {d.validation.issues.slice(0, 5).map(issue => (
367                <Text dimColor wrap="truncate-end">  {issue}</Text>
368              ))}
369            </Box>
370          )}
371          {sections.map(section => {
372            const done = section.tasks.filter(t => t.isDone).length
373            return (
374              <Box flexDirection="column" marginTop={1}>
375                <Text bold wrap="truncate-end">
376                  {section.title} <Text dimColor>{done}/{section.tasks.length}</Text>
377                </Text>
378                {section.tasks.map(task => (
379                  <Text wrap="truncate-end" dimColor={task.isDone}>
380                    <Text color={task.isDone ? 'green' : undefined}>{task.isDone ? ' ✓ ' : ' ○ '}</Text>
381                    {task.text}
382                  </Text>
383                ))}
384              </Box>
385            )
386          })}
387        </Box>
388      )
389    }
390
391    const active = await read($, current)
392    const now = await $.clock.now()
393    return (
394      <Box flexDirection="column">
395        <Box columnGap={2}>
396          <Text bold>
397            {snap.changes.length} {snap.changes.length === 1 ? 'cambio activo' : 'cambios activos'}
398          </Text>
399          {refreshButton}
400        </Box>
401        {snap.changes.length === 0 && <Text dimColor>Nada en curso. Crea uno con /opsx:propose.</Text>}
402        {snap.changes.map((change, i) => (
403          <Box flexDirection="column" marginTop={1}>
404            <Box>
405              <Button
406                key={`open-${change.name}`}
407                plain
408                {...(i < 9 ? { hotkey: String(i + 1) } : {})}
409                label={`${change.name === active ? '◐ ' : ''}${change.name}`}
410                onPress={() => openDetail($, change.name)}
411              />
412            </Box>
413            <Text>
414              {'   '}
415              <Text color="cyan">{PHASE_LABEL[change.phase] ?? change.phase}</Text>
416              {change.total > 0 && (
417                <Text>
418                  {'  '}
419                  <Text color={change.completed === change.total ? 'green' : 'yellow'}>
420                    {progressBar(change.completed, change.total, width)}
421                  </Text>
422                  <Text dimColor> {change.completed}/{change.total}</Text>
423                </Text>
424              )}
425              <Text dimColor>  {relativeTime(change.lastModified, now)}</Text>
426            </Text>
427          </Box>
428        ))}
429      </Box>
430    )
431  })
432}
433
types/index.d.ts 50 lines
1export type ArtifactStatus = 'done' | 'ready' | 'blocked' | string
2
3export type Artifact = { id: string; status: ArtifactStatus }
4
5/** Fase del flujo: el primer artefacto pendiente, `apply` o `archive`. */
6export type Phase = string
7
8export type ChangeInfo = {
9  name: string
10  completed: number
11  total: number
12  lastModified: string
13  artifacts: Artifact[]
14  phase: Phase
15}
16
17export type Snapshot = {
18  root: string
19  changes: ChangeInfo[]
20  /** Por qué no se pudo leer el estado (sin CLI, sin carpeta openspec/). */
21  error: string | null
22  refreshedAt: number
23}
24
25export type TaskItem = { text: string; isDone: boolean }
26
27export type TaskSection = { title: string; tasks: TaskItem[] }
28
29export type Validation = { isValid: boolean; issues: string[] }
30
31export type Detail = {
32  name: string
33  sections: TaskSection[]
34  validation: Validation | null
35  isValidating: boolean
36}
37
38export type View = { kind: 'list' } | { kind: 'detail'; name: string }
39
40declare module 'claude-code' {
41  interface PluginState {
42    'openspec-tracker': {
43      snapshot: Snapshot | null
44      current: string | null
45      view: View
46      detail: Detail | null
47    }
48  }
49}
50