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

Marketplace de mods para Claude Code.
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.
Vista limpia mientras Claude trabaja:
Pensando · paso 3/5….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 View y Context Bar en un único mod: una caja sobre el prompt, con dos secciones.
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)./compact.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.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.
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.tasks.md por sección y validación con openspec validate.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.◐ OpenSpec 05-update-member-screens-ux · 7/15 · implementación.Requiere la CLI openspec (v1.x) instalada. Solo se activa en proyectos con carpeta openspec/.
hooks/register.tsx 433 lines1import { 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}
433types/index.d.ts 50 lines1export 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