SLOPSHOPPER

tarea-progreso

Barra de avance de la tarea en curso y reporte para corregir CLAUDE.md cuando algo no se logra

newpanerowsguardcommandtoast
v0.3.0no licenseupdated 2026-10-13LordMordelon/claude-mods/tarea-progreso
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · tarea-progreso
│ ┃ Progreso de la tarea ✕ › fix the failing auth test and add an audit log call │ ┃ Sin tarea en curso. Empieza sola con «tarea │ ┃ N». «/progreso demo» muestra un ejemplo. ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ Apagar seguimiento ] ⏺ 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 │ │ › /progreso │ ⎿ tarea-progreso: No hay una tarea en curso. El seguimiento empiez │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Progreso de la tarea
Sin tarea en curso. Empieza sola con «tarea N». «/progreso demo» muestra un ejemplo. [ Apagar seguimiento ]
README

claude-mods

Mods de Claude Code para los proyectos de Angel (Vremia).

tarea-progreso

Un panel en el chat con el avance de la tarea en curso: los 8 pasos del cierre (rama, plan, pruebas, commit, PR, reporte de consumo, pruebas de GitHub, unión) y los criterios de «Terminado cuando» del issue. Empieza solo al crear la rama tarea/N-… o al marcar la tarea en-curso; en la nube, donde la sesión trae su propia rama (claude/…), esa cuenta como la rama de la tarea. Si una acción esperada no se logra (pruebas rojas, PR que no se crea, un permiso negado, un criterio que no se pudo), escribe storage/reporte-claude-tarea-N.md con una propuesta para corregir CLAUDE.md.

  • Siempre encendido, con interruptor: /progreso apagar (o el botón «Apagar seguimiento» del panel o de la tarjeta) lo apaga: no sigue nada, no escribe reportes y cierra el panel. /progreso encender (o el botón) lo vuelve a encender. Se recuerda entre conversaciones.
  • El panel, a la vista en el chat de VS Code: el chat es angosto y un panel que el mod abre solo espera una pantalla ancha; por eso cada mensaje de la persona lo trae a la vista (abierto en respuesta a ella, se dibuja a cualquier ancho). Si se cierra a mano, no vuelve solo hasta la próxima tarea («tarea N») o /progreso.
  • /progreso como tarjeta, no como texto: el avance con colores y marcas, y los botones «Ver panel» y «Apagar seguimiento». /progreso demo: un ejemplo. /progreso limpiar: lo vacía.
  • Donde no hay panel (la app y la web de Claude): una línea en el chat por cada avance, como «✅ Tarea #112 · Criterio 2 de 4: Una vez al día · criterios 2/4», y «🏁 cierre completo» al final. Sale mientras el panel no se ve en ninguna pantalla y también mientras haya un celular conectado, que no dibuja el panel aunque la web o el escritorio sí lo tengan. Si el panel no se ve, la tarjeta de /progreso dice por qué, con el motivo que da Claude Code.
  • El avance en la tarea de GitHub: un solo comentario en el issue, «Avance de la tarea #N», con los pasos del cierre y los criterios; se crea al empezar y después se edita solo (editar no manda avisos). Se ve desde cualquier celular, con o sin la app de Claude. Necesita gh con permiso para comentar; si no lo tiene, todo lo demás sigue igual. Apagado el seguimiento, no se escribe.

Activarlo en un proyecto

En el .claude/settings.json del proyecto:

{
  "extraKnownMarketplaces": {
    "claude-mods": { "source": { "source": "github", "repo": "LordMordelon/claude-mods" } }
  },
  "enabledPlugins": { "tarea-progreso@claude-mods": true }
}

Revisarlo

claude plugin validate tarea-progreso
claude plugin test tarea-progreso
Source 3 files
hooks/register.tsx 509 lines
1// tarea-progreso: el avance de la tarea en curso (issue de GitHub) en un panel del chat, y un reporte para corregir
2// CLAUDE.md cuando una accion que se esperaba no se logra. Sigue el flujo de las skills `tarea` y `cerrar-tarea`.
3// Esta siempre encendido; «/progreso apagar» (o el boton del panel) lo apaga hasta «/progreso encender».
4
5import type { EngineInterface, Register } from 'claude-code'
6
7import type { Incidente, Tarea } from '../types'
8import { PASOS, avance, barra, cambios, comentario, corto, criterios, esBloqueo, incidentePaso, leerComando, ramaDeTarea, reporte, repositorio } from './logica'
9
10const HERRAMIENTA = 'mcp__tarea-progreso__progreso'
11
12/** El panel: lo dibujan todas las superficies, tambien el chat de VS Code (la banda sobre el prompt no). */
13const PANEL = 'tarea-progreso'
14
15/**
16 * La tarea en curso. Vive en el modulo porque este build no deja leer $.state mientras se dibuja (y el kit de pruebas
17 * no lo tiene); se copia en $.state cuando se puede, para reponerla si el modulo se recarga.
18 */
19let vista: Tarea | null = null
20
21/** El interruptor: encendido salvo que la persona lo apague. Se recuerda entre conversaciones ($.store). */
22let activo = true
23
24/** La persona cerro el panel a mano: no se vuelve a abrir solo hasta la proxima tarea o «/progreso». */
25let cerradoAMano = false
26
27/** Por que el panel no se vio la ultima vez que «/progreso» lo pidio (null: se vio). Lo muestra la tarjeta. */
28let motivoPanel: string | null = null
29
30/** Si el panel se ve ahora. Mientras se ve, el avance va en el panel y no como lineas sueltas en el chat. */
31async function panelVisible($: EngineInterface): Promise<boolean> {
32  try {
33    return (await $.ui.panes()).some(p => p.id === PANEL && p.isPlaced)
34  } catch {
35    return false
36  }
37}
38
39/**
40 * Si el avance va tambien como lineas en el chat: cuando el panel no se ve en ninguna pantalla, y cuando hay un celular
41 * conectado (#4), que no dibuja el panel aunque otra pantalla (la web, el escritorio) si lo tenga a la vista.
42 */
43async function avisarEnChat($: EngineInterface): Promise<boolean> {
44  if (!(await panelVisible($))) {
45    return true
46  }
47  try {
48    return (await $.session.surfaces()).includes('mobile')
49  } catch {
50    return false
51  }
52}
53
54/** Guarda la tarea en el estado de la sesion y entre conversaciones, sin redibujar ni avisar. */
55async function persistir($: EngineInterface, tarea: Tarea): Promise<void> {
56  try {
57    await $.state.set({ plugin: 'tarea-progreso', key: 'tarea' }, tarea)
58  } catch {
59    // Sin $.state, la copia del modulo basta para esta sesion.
60  }
61  // Entre conversaciones: un chat nuevo sigue mostrando la tarea en curso.
62  await $.store.set('tarea', tarea).catch(() => undefined)
63}
64
65/**
66 * Guarda la tarea y redibuja. Donde el panel no se ve (la app de Claude en el celular, la web) deja en el chat una
67 * linea por cada cosa que cambio, y deja el avance al dia en el comentario del issue, para que no se pierda.
68 */
69async function guardar($: EngineInterface, tarea: Tarea): Promise<void> {
70  const antes = vista
71  vista = tarea
72  $.ui.invalidate('ui.render')
73  if (await avisarEnChat($)) {
74    for (const linea of cambios(antes, tarea)) {
75      $.ui.log(linea)
76    }
77  }
78  await persistir($, tarea)
79  await publicar($)
80}
81
82/** Lo que se esta escribiendo en GitHub: un comentario a la vez, para no crear dos si el avance llega seguido. */
83let publicando: Promise<void> = Promise.resolve()
84
85/**
86 * Deja el avance de la tarea en curso en un solo comentario de su issue (#4): lo crea la primera vez y despues lo edita
87 * (editar no manda avisos). Sin gh, sin red o sin permiso, sigue todo igual: el comentario es una ayuda, no un paso.
88 */
89function publicar($: EngineInterface): Promise<void> {
90  publicando = publicando.then(async () => {
91    const t = vista
92    if (!activo || t === null || t.repo === null || t.numero <= 0) {
93      return
94    }
95    const cuerpo = comentario(t)
96    try {
97      if (t.comentario === undefined || t.comentario === null) {
98        const r = await $.process.run(
99          ['gh', 'api', `repos/${t.repo}/issues/${t.numero}/comments`, '-f', `body=${cuerpo}`, '--jq', '.id'], { timeoutMs: 20000 })
100        const id = Number(r.stdout.trim())
101        const actual = vista
102        if (r.exitCode === 0 && Number.isInteger(id) && id > 0 && actual !== null && actual.numero === t.numero) {
103          vista = { ...actual, comentario: id }
104          await persistir($, vista)
105        }
106      } else {
107        await $.process.run(
108          ['gh', 'api', '-X', 'PATCH', `repos/${t.repo}/issues/comments/${t.comentario}`, '-f', `body=${cuerpo}`], { timeoutMs: 20000 })
109      }
110    } catch {
111      // El comentario es una ayuda: si GitHub no responde, el panel y el chat siguen igual.
112    }
113  })
114  return publicando
115}
116
117/** La tarea N recien empezada: su titulo y sus criterios, del issue. Sin gh o sin red, solo los pasos del cierre. */
118async function cargarTarea($: EngineInterface, numero: number): Promise<Tarea> {
119  let titulo = ''
120  let lista: string[] = []
121  let repo: string | null = null
122  try {
123    repo = repositorio((await $.process.run(['git', 'remote', 'get-url', 'origin'])).stdout)
124    if (repo !== null) {
125      const r = await $.process.run(['gh', 'api', `repos/${repo}/issues/${numero}`, '--jq', '{title, body}'], { timeoutMs: 20000 })
126      const issue = JSON.parse(r.stdout) as { title?: string; body?: string }
127      titulo = issue.title ?? ''
128      lista = criterios(issue.body ?? '')
129    }
130  } catch {
131    // La barra sigue igual, sin los criterios.
132  }
133  $.ui.toast(`Siguiendo la tarea #${numero}${lista.length > 0 ? ` (${lista.length} criterios)` : ''}`)
134  return { numero, titulo, repo, criterios: lista, criteriosEstado: {}, pasos: {}, incidentes: [], reporte: null }
135}
136
137/** Escribe el reporte en storage/ (storage/* no se sube al repositorio). Devuelve la ruta, o null si no se pudo. */
138async function escribirReporte($: EngineInterface, tarea: Tarea, ultimo: Incidente): Promise<string | null> {
139  const nombre = `storage/reporte-claude-tarea-${tarea.numero}.md`
140  try {
141    await $.fs.write(`${await $.session.root()}/${nombre}`, reporte(tarea))
142    $.ui.toast(`Incidente anotado: ${ultimo.esperado}. Reporte en ${nombre}`)
143    return nombre
144  } catch {
145    $.ui.toast(`Incidente anotado: ${ultimo.esperado} (no se pudo escribir el reporte)`)
146    return null
147  }
148}
149
150/** La tarea de muestra de «/progreso demo». */
151const EJEMPLO: Tarea = {
152  numero: 0,
153  titulo: 'Ejemplo: Correos de aviso',
154  repo: null,
155  criterios: ['Llega el aviso inmediato a Angel en modo de prueba', 'Resumen diario a las 8:00', 'Captura del correo revisada'],
156  criteriosEstado: { 0: 'hecho', 1: 'no_se_pudo' },
157  pasos: { rama: 'ok', plan: 'ok', pruebas: 'ok', commit: 'falla' },
158  incidentes: [{
159    cuando: 'ejemplo',
160    esperado: 'Commit',
161    paso: 'El paso «Commit» no se logró.',
162    detalle: 'error: ejemplo',
163    propuesta: 'Revisar qué impidió el commit (hook, identidad de git, archivos sin agregar).',
164  }],
165  reporte: 'storage/reporte-claude-tarea-0.md (ejemplo, no se escribe)',
166}
167
168/**
169 * Abre (o trae al frente) el panel de la tarea. El chat de VS Code es angosto: un panel que el mod abre solo espera una
170 * pantalla ancha, pero uno abierto en respuesta a la persona (su mensaje, su comando, un boton) se ve a cualquier
171 * ancho. Devuelve el motivo del motor cuando el panel queda abierto sin dibujar, o null si se ve.
172 */
173async function abrirPanel($: EngineInterface, numero: number | null): Promise<string | null> {
174  try {
175    const r = await $.ui.open({ id: PANEL, title: numero === null ? 'Progreso de la tarea' : `Tarea #${numero}` })
176    return r.isPlaced ? null : r.reason
177  } catch (error) {
178    return `no se pudo abrir (${String(error)})`
179  }
180}
181
182/** El interruptor. Apagado: se cierra el panel y no se sigue nada; encendido: vuelve el panel. */
183async function cambiarActivo($: EngineInterface, valor: boolean): Promise<void> {
184  activo = valor
185  await $.store.set('activo', valor).catch(() => undefined)
186  if (valor) {
187    cerradoAMano = false
188    void abrirPanel($, vista?.numero ?? null)
189  } else {
190    await $.ui.close({ id: PANEL }).catch(() => undefined)
191  }
192  $.ui.invalidate('ui.render')
193}
194
195/** La rama en que esta la carpeta, o '' si no se puede saber. */
196async function ramaActual($: EngineInterface): Promise<string> {
197  try {
198    return (await $.process.run(['git', 'branch', '--show-current'])).stdout.trim()
199  } catch {
200    return ''
201  }
202}
203
204async function hora($: EngineInterface): Promise<string> {
205  return new Date(await $.clock.now()).toLocaleString('es-CL', { timeZone: 'America/Santiago' })
206}
207
208/** El texto de «/progreso» (lo que queda guardado en el chat y lee el modelo; la tarjeta se dibuja aparte). */
209function resumen(tarea: Tarea): string {
210  const a = avance(tarea)
211  const lineas = [
212    `Tarea #${tarea.numero}${tarea.titulo !== '' ? ` · ${tarea.titulo}` : ''}`,
213    `Cierre ${barra(a.hechos, a.total)} ${a.hechos}/${a.total}`,
214    ...PASOS.map(p => `  ${tarea.pasos[p.id] === 'ok' ? '✓' : tarea.pasos[p.id] === 'falla' ? '✗' : '·'} ${p.nombre}`),
215  ]
216  if (tarea.criterios.length > 0) {
217    lineas.push('Terminado cuando:')
218    tarea.criterios.forEach((c, i) => {
219      const s = tarea.criteriosEstado[i]
220      lineas.push(`  ${s === 'hecho' ? '✓' : s === 'no_se_pudo' ? '✗' : '·'} ${i + 1}. ${c}`)
221    })
222  }
223  lineas.push(tarea.incidentes.length === 0 ? 'Sin incidentes.' : `${tarea.incidentes.length} incidente(s): ${tarea.reporte ?? 'reporte sin escribir'}`)
224  return lineas.join('\n')
225}
226
227export const register: Register = on => {
228  on('session.start', async ($, e, next) => {
229    const r = await next(e)
230    await $.tool.register({
231      name: 'progreso',
232      description:
233        'Marca un criterio de «Terminado cuando» de la tarea en curso (la barra de progreso de Angel). Llamarla al ' +
234        'cumplir cada criterio, y con estado "no_se_pudo" y una nota cuando uno no se puede lograr: queda en el reporte ' +
235        'para corregir CLAUDE.md. Los criterios van numerados desde 1, en el orden del issue.',
236      inputSchema: {
237        type: 'object',
238        properties: {
239          criterio: { type: 'integer', minimum: 1, description: 'Número del criterio, desde 1.' },
240          estado: { type: 'string', enum: ['hecho', 'no_se_pudo'] },
241          nota: { type: 'string', description: 'Por qué no se pudo (obligatoria con no_se_pudo).' },
242        },
243        required: ['criterio', 'estado'],
244      },
245      isDeferred: false,
246    })
247    await $.command.register({
248      name: 'progreso',
249      description: 'Avance de la tarea en curso; apagar o encender el seguimiento',
250      argumentHint: '[apagar | encender | demo | limpiar]',
251    })
252    try {
253      activo = ((await $.store.get('activo')) as boolean | undefined) ?? true
254    } catch {
255      // Sin store: encendido.
256    }
257    try {
258      vista ??= (await $.state.get({ plugin: 'tarea-progreso', key: 'tarea' })).value ?? null
259    } catch {
260      // Sin $.state: se empieza sin tarea.
261    }
262    if (vista === null) {
263      try {
264        vista = ((await $.store.get('tarea')) as Tarea | undefined) ?? null
265      } catch {
266        // Sin store: se empieza sin tarea.
267      }
268    }
269    // Abierto aqui espera una pantalla ancha; el primer mensaje de la persona lo pone a la vista (prompt.submit).
270    if (activo) {
271      void abrirPanel($, vista?.numero ?? null)
272    }
273    return r
274  })
275
276  // Cada mensaje de la persona trae el panel a la vista: abierto en respuesta a ella, se dibuja tambien en el chat
277  // angosto de VS Code. «tarea N» lo abre siempre, aunque lo haya cerrado antes.
278  on('prompt.submit', async ($, e, next) => {
279    if (activo) {
280      const pedido = /^\s*(?:toma (?:la )?)?tarea\s+(\d+)\b/i.exec(e.text)
281      if (pedido !== null) {
282        cerradoAMano = false
283        void abrirPanel($, Number(pedido[1]))
284      } else if (!cerradoAMano && !(await panelVisible($))) {
285        void abrirPanel($, vista?.numero ?? null)
286      }
287    }
288    return next(e)
289  })
290
291  on('ui.close', async ($, e, next) => {
292    if (e.id === PANEL && e.origin.kind === 'person') {
293      cerradoAMano = true
294    }
295    return next(e)
296  })
297
298  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
299    const ran = await next(e)
300    if (!activo) {
301      return ran
302    }
303    try {
304      const texto = ran.text ?? ran.deny ?? ''
305      const conError = ran.deny !== undefined || ran.isError === true
306      const nuevos: Incidente[] = []
307      for (const l of leerComando(e.command, texto, conError)) {
308        if (l.tipo === 'inicio') {
309          const actual = vista
310          if (actual === null || actual.numero !== l.numero) {
311            const tarea = await cargarTarea($, l.numero)
312            await guardar($, tarea)
313            cerradoAMano = false
314            void abrirPanel($, tarea.numero)
315          }
316          // En la nube la rama la trae la sesion y no se crea una tarea/N-…: si ya se esta en la rama de la tarea al
317          // marcarla en curso, el paso esta hecho.
318          const t = vista
319          if (t !== null && t.pasos.rama !== 'ok' && ramaDeTarea(await ramaActual($), t.numero)) {
320            await guardar($, { ...t, pasos: { ...t.pasos, rama: 'ok' } })
321          }
322        } else if (l.tipo === 'paso') {
323          // Un paso que ya salio bien no vuelve a falla por un intento posterior (correr un grupo de pruebas, por ej.).
324          const t = vista
325          if (t !== null) {
326            await guardar($, { ...t, pasos: { ...t.pasos, [l.paso]: l.ok || t.pasos[l.paso] === 'ok' ? 'ok' : 'falla' } })
327          }
328          if (!l.ok) {
329            nuevos.push(incidentePaso(l.paso, l.detalle, await hora($)))
330          }
331        }
332      }
333      // Lo que la persona o un permiso no dejo hacer, durante una tarea, tambien va al reporte.
334      if (conError && esBloqueo(texto)) {
335        nuevos.push({
336          cuando: await hora($),
337          esperado: 'Comando permitido',
338          paso: `No se dejó ejecutar: \`${corto(e.command).slice(0, 160)}\``,
339          detalle: corto(texto),
340          propuesta: 'Si la acción no debía pedirse, la tabla de Permisos de CLAUDE.md o `.claude/settings.json` debería decirlo.',
341        })
342      }
343      const tarea = vista
344      if (nuevos.length > 0 && tarea !== null) {
345        const conIncidentes: Tarea = { ...tarea, incidentes: [...tarea.incidentes, ...nuevos] }
346        const ruta = await escribirReporte($, conIncidentes, nuevos[nuevos.length - 1]!)
347        await guardar($, { ...conIncidentes, reporte: ruta })
348      }
349    } catch {
350      // El seguimiento nunca puede romper el comando: el resultado sale igual.
351    }
352    return ran
353  })
354
355  on('tool.call', { tool: HERRAMIENTA }, async ($, e) => {
356    const entrada = e as unknown as { criterio?: number; estado?: string; nota?: string }
357    if (!activo) {
358      return { result: 'El seguimiento está apagado (Angel lo encendería con «/progreso encender»); no se marcó nada.' }
359    }
360    const tarea = vista
361    if (tarea === null) {
362      return { result: 'No hay una tarea en curso: la barra empieza al crear la rama tarea/<N>-… o marcar la tarea en curso.' }
363    }
364    const i = (entrada.criterio ?? 0) - 1
365    if (i < 0 || i >= tarea.criterios.length) {
366      return { result: `La tarea #${tarea.numero} tiene ${tarea.criterios.length} criterios; el ${entrada.criterio} no existe.` }
367    }
368    const estado = entrada.estado === 'no_se_pudo' ? 'no_se_pudo' : 'hecho'
369    let nueva: Tarea = { ...tarea, criteriosEstado: { ...tarea.criteriosEstado, [i]: estado } }
370    if (estado === 'no_se_pudo') {
371      const inc: Incidente = {
372        cuando: await hora($),
373        esperado: `Criterio ${i + 1}: ${tarea.criterios[i]}`,
374        paso: entrada.nota !== undefined && entrada.nota !== '' ? entrada.nota : 'No se pudo cumplir (sin nota).',
375        detalle: '',
376        propuesta: 'Si el criterio no se puede cumplir así, cambiar la plantilla de tareas o la regla de «Terminado significa» en CLAUDE.md.',
377      }
378      nueva = { ...nueva, incidentes: [...nueva.incidentes, inc] }
379      nueva = { ...nueva, reporte: await escribirReporte($, nueva, inc) }
380    }
381    await guardar($, nueva)
382    const hechos = Object.values(nueva.criteriosEstado).filter(x => x === 'hecho').length
383    return { result: `Criterio ${i + 1} marcado como ${estado}. Van ${hechos} de ${tarea.criterios.length}.` }
384  })
385
386  // El texto que devuelve queda guardado en el chat; en pantalla se dibuja la tarjeta (ui.render de CommandOutput).
387  on('command.run', { command: 'progreso' }, async ($, e) => {
388    const pedido = e.args.trim()
389    if (pedido === 'apagar') {
390      await cambiarActivo($, false)
391      return { text: 'Seguimiento apagado. «/progreso encender» lo vuelve a encender.' }
392    }
393    if (pedido === 'encender') {
394      await cambiarActivo($, true)
395      return { text: 'Seguimiento encendido.' }
396    }
397    if (pedido === 'limpiar') {
398      vista = null
399      $.ui.invalidate('ui.render')
400      await $.store.delete('tarea').catch(() => undefined)
401      return { text: 'Sin tarea en curso.' }
402    }
403    if (!activo) {
404      return { text: 'El seguimiento está apagado. «/progreso encender» lo vuelve a encender.' }
405    }
406    // Para ver el panel sin empezar una tarea: datos de ejemplo, sin ramas ni GitHub. Una tarea real los reemplaza.
407    if (pedido === 'demo') {
408      await guardar($, EJEMPLO)
409    }
410    cerradoAMano = false
411    // Si la pantalla no dibuja el panel, se dice con el motivo del motor, para saber que arreglar.
412    motivoPanel = await abrirPanel($, vista?.numero ?? null)
413    const nota = motivoPanel === null ? '' : `\nPanel: no se ve en esta pantalla. Motivo: ${motivoPanel}`
414    const tarea = vista
415    if (tarea === null) {
416      return { text: `No hay una tarea en curso. El seguimiento empieza solo con «tarea N» (skill tarea).${nota}` }
417    }
418    return { text: resumen(tarea) + nota }
419  })
420
421  // La salida de «/progreso» como tarjeta en el chat (todas las pantallas), con el interruptor a mano.
422  on('ui.render', { component: 'CommandOutput' }, async ($, e, next) => {
423    if (e.props.command !== 'progreso' || e.props.isErrored) {
424      return next(e)
425    }
426    const { Box, Text, Button } = $.ui.resolve(e)
427    const interruptor = activo
428      ? <Button onPress={() => void cambiarActivo($, false)}>Apagar seguimiento</Button>
429      : <Button variant="primary" onPress={() => void cambiarActivo($, true)}>Encender seguimiento</Button>
430    const args = e.props.args.trim()
431    if (!activo || args === 'apagar' || args === 'encender' || args === 'limpiar' || vista === null) {
432      return (
433        <Box flexDirection="column" borderStyle="round" paddingX={1}>
434          <Text>{activo ? '🟢' : '⚪'} {e.props.text}</Text>
435          <Box marginTop={1}>{interruptor}</Box>
436        </Box>
437      )
438    }
439    return (
440      <Box flexDirection="column" borderStyle="round" paddingX={1}>
441        {tarjeta($, e, vista)}
442        {motivoPanel !== null ? <Text color="warning" wrap="wrap">El panel no se ve en esta pantalla: {motivoPanel}</Text> : null}
443        <Box marginTop={1} gap={1}>
444          <Button variant="primary" onPress={() => void abrirPanel($, vista?.numero ?? null)}>Ver panel</Button>
445          {interruptor}
446        </Box>
447      </Box>
448    )
449  })
450
451  on('ui.render', { component: 'Pane', requestId: PANEL }, async ($, e) => {
452    const { Box, Text, Button } = $.ui.resolve(e)
453    if (!activo) {
454      return (
455        <Box flexDirection="column">
456          <Text dimColor>Seguimiento apagado.</Text>
457          <Button variant="primary" onPress={() => void cambiarActivo($, true)}>Encender seguimiento</Button>
458        </Box>
459      )
460    }
461    const tarea = vista
462    return (
463      <Box flexDirection="column">
464        {tarea === null
465          ? <Text dimColor wrap="wrap">Sin tarea en curso. Empieza sola con «tarea N». «/progreso demo» muestra un ejemplo.</Text>
466          : tarjeta($, e, tarea)}
467        <Box marginTop={1}>
468          <Button onPress={() => void cambiarActivo($, false)}>Apagar seguimiento</Button>
469        </Box>
470      </Box>
471    )
472  })
473}
474
475/** El avance de la tarea, igual en el panel y en la tarjeta de «/progreso». */
476function tarjeta($: EngineInterface, e: Parameters<EngineInterface['ui']['resolve']>[0], tarea: Tarea) {
477  const { Box, Text } = $.ui.resolve(e)
478  const a = avance(tarea)
479  const hechosCrit = Object.values(tarea.criteriosEstado).filter(x => x === 'hecho').length
480  const marca = (s: string | undefined): string => (s === 'ok' || s === 'hecho' ? '✓' : s === 'falla' || s === 'no_se_pudo' ? '✗' : '·')
481  const color = (s: string | undefined): string | undefined =>
482    s === 'ok' || s === 'hecho' ? 'success' : s === 'falla' || s === 'no_se_pudo' ? 'error' : undefined
483  return (
484    <Box flexDirection="column">
485      <Text bold wrap="truncate">Tarea #{tarea.numero}{tarea.titulo !== '' ? ` · ${tarea.titulo}` : ''}</Text>
486      <Text>
487        <Text color={a.hechos === a.total ? 'success' : undefined}>{barra(a.hechos, a.total)}</Text>
488        <Text dimColor> {a.hechos}/{a.total}{a.siguiente !== null ? ` · sigue: ${a.siguiente}` : ' · cierre completo'}</Text>
489      </Text>
490      {PASOS.map(p => (
491        <Text key={p.id} color={color(tarea.pasos[p.id])} dimColor={tarea.pasos[p.id] === undefined}>
492          {marca(tarea.pasos[p.id])} {p.nombre}
493        </Text>
494      ))}
495      {tarea.criterios.length > 0 ? <Text bold>Terminado cuando ({hechosCrit}/{tarea.criterios.length})</Text> : null}
496      {tarea.criterios.map((c, i) => (
497        <Text key={`c${i}`} color={color(tarea.criteriosEstado[i])} dimColor={tarea.criteriosEstado[i] === undefined} wrap="wrap">
498          {marca(tarea.criteriosEstado[i])} {i + 1}. {c}
499        </Text>
500      ))}
501      {tarea.incidentes.length > 0 ? (
502        <Text color="warning" wrap="wrap">
503          ⚠ {tarea.incidentes.length} incidente{tarea.incidentes.length === 1 ? '' : 's'}: {tarea.reporte ?? 'reporte sin escribir'}
504        </Text>
505      ) : null}
506    </Box>
507  )
508}
509
hooks/logica.ts 234 lines
1// Lo que el mod entiende de los comandos de una tarea (flujo de las skills `tarea` y `cerrar-tarea`), sin tocar el
2// motor: funciones puras, para poder probarlas.
3
4import type { Incidente, PasoId, Tarea } from '../types'
5
6/** Los pasos de «Terminado significa» (CLAUDE.md) en el orden en que la skill cerrar-tarea los hace. */
7export const PASOS: readonly { id: PasoId; nombre: string; consejo: string }[] = [
8  { id: 'rama', nombre: 'Rama de la tarea', consejo: 'Si la carpeta principal la usa otra sesión, trabajar en un `git worktree` aparte.' },
9  { id: 'plan', nombre: 'Plan en la tarea', consejo: 'Dejar el plan como comentario antes del código; si es grande, esperar el «Aprobado».' },
10  { id: 'pruebas', nombre: 'Pruebas en verde', consejo: 'Si la falla es del entorno (PHP, vendor/, base) y no del código, anotar en CLAUDE.md cómo prepararlo.' },
11  { id: 'commit', nombre: 'Commit', consejo: 'Revisar qué impidió el commit (hook, identidad de git, archivos sin agregar).' },
12  { id: 'pr', nombre: 'PR abierto', consejo: 'Revisar la rama subida y los permisos de `gh api`; la regla de GitHub solo por REST está en CLAUDE.md.' },
13  { id: 'consumo', nombre: 'Reporte de consumo', consejo: 'Si el script no encuentra la sesión, pasarle la ruta del .jsonl (sesiones en paralelo).' },
14  { id: 'ci', nombre: 'Pruebas de GitHub', consejo: 'Si se esperó con Monitor, confirmar al final con `gh api $R/commits/<sha>/check-runs` en Bash (el mod no ve los avisos de Monitor). Si falló, anotar qué prueba y si el flujo local no la detecta.' },
15  { id: 'union', nombre: 'Unido o pedido de aprobación', consejo: 'Unir con un comando solo, sin encadenar otros; confirmarlo con `gh api $R/pulls/<PR> --jq .merged`. Si es grande, el mensaje de unión va como comentario del PR y se espera a Angel.' },
16]
17
18export type Lectura =
19  | { tipo: 'inicio'; numero: number }
20  | { tipo: 'paso'; paso: PasoId; ok: boolean; detalle: string }
21  | { tipo: 'nada' }
22
23/** Los comandos de pruebas de otros proyectos; `tests/run.php` (licitaciones) tiene su propio formato de salida. */
24const PRUEBAS_OTRAS = /\b(python3?\s+-m\s+(unittest|pytest)|pytest|phpunit|artisan\s+test|(npm|pnpm|yarn)\s+(run\s+)?test|composer\s+test)\b/
25/** Lo que dicen de una falla unittest, pytest, PHPUnit y Jest. */
26const FALLAS = /\b[1-9]\d* (failed|failing)\b|\bFAILED\b|FAILURES!|ERRORS!/
27
28/** Que dice un comando de Bash ya ejecutado sobre la tarea. `salida` es lo que leyo el modelo. */
29export function leerComando(comando: string, salida: string, conError: boolean): Lectura[] {
30  const lecturas: Lectura[] = []
31  const rama = /(?:switch -c|checkout -b|worktree add\b[^\n]*?-b)\s+tarea\/(\d+)-/.exec(comando)
32  const enCurso = /issues\/(\d+)\/labels\b[^\n]*en-curso/.exec(comando)
33  if (rama !== null || (enCurso !== null && !/-X DELETE/.test(comando))) {
34    lecturas.push({ tipo: 'inicio', numero: Number((rama ?? enCurso)![1]) })
35  }
36  if (rama !== null) {
37    lecturas.push({ tipo: 'paso', paso: 'rama', ok: !conError, detalle: corto(salida) })
38  }
39  if (/issues\/\d+\/comments\b/.test(comando) && /-[fF] body/.test(comando)) {
40    lecturas.push({ tipo: 'paso', paso: 'plan', ok: !conError, detalle: corto(salida) })
41  }
42  if (/tests\/run\.php/.test(comando)) {
43    const pruebas = /Pruebas: (\d+) bien, (\d+) fallidas/.exec(salida)
44    const sintaxis = /Sintaxis: \d+ archivos, (\d+) con errores/.exec(salida)
45    const fallidas = pruebas === null ? null : Number(pruebas[2])
46    const malas = sintaxis === null ? 0 : Number(sintaxis[1])
47    // Correr solo un grupo (`run.php Algo`) tambien cuenta: el verde final lo confirma la corrida completa. Pero que un
48    // grupo falle no es incidente: con «pruebas primero» la prueba nueva falla a proposito antes del codigo (#122).
49    // Los errores de sintaxis si, porque se revisan todos los archivos igual.
50    const parcial = /tests\/run\.php[ \t]+(?![0-9]?[>|&;])\S/.test(comando)
51    const rojoBuscado = parcial && malas === 0 && fallidas !== null && fallidas > 0
52    if ((fallidas !== null || malas > 0) && !rojoBuscado) {
53      const ok = fallidas === 0 && malas === 0 && !conError
54      lecturas.push({ tipo: 'paso', paso: 'pruebas', ok, detalle: ok ? corto(pruebas![0]) : corto(salida) })
55    }
56  } else if (PRUEBAS_OTRAS.test(comando)) {
57    // Otros proyectos (#139): no hay un formato propio, asi que manda el codigo de salida y, como con `| tail` el codigo
58    // es el del ultimo comando, tambien lo que la salida diga de fallas. Mismo criterio de grupo que arriba.
59    const rojo = conError || FALLAS.test(salida)
60    const parcial = /(--filter\b|\s-k\s|\s--\s+[^\s|;&>]|[^\s|;&>]*test[^\s|;&>]*\.(py|php|js|ts)\b|::)/i.test(comando)
61    if (!(rojo && parcial)) {
62      lecturas.push({ tipo: 'paso', paso: 'pruebas', ok: !rojo, detalle: corto(salida) })
63    }
64  }
65  if (/\bgit\b[^\n|;&]*\bcommit\b/.test(comando)) {
66    lecturas.push({ tipo: 'paso', paso: 'commit', ok: !conError, detalle: corto(salida) })
67  }
68  if (/\/pulls\b[^\n]*-f title=/.test(comando)) {
69    lecturas.push({ tipo: 'paso', paso: 'pr', ok: !conError && /\d/.test(salida), detalle: corto(salida) })
70  }
71  if (/consumo\.py/.test(comando)) {
72    lecturas.push({ tipo: 'paso', paso: 'consumo', ok: !conError && !/Traceback|No se encontr/i.test(salida), detalle: corto(salida) })
73  }
74  if (/check-runs/.test(comando) && /completed/.test(salida)) {
75    const malo = /completed (failure|cancelled|timed_out|action_required)/.exec(salida)
76    const pendiente = /\b(queued|in_progress)\b/.test(salida)
77    if (malo !== null || !pendiente) {
78      lecturas.push({ tipo: 'paso', paso: 'ci', ok: malo === null, detalle: corto(salida) })
79    }
80  }
81  if (/pulls\/\d+\/merge/.test(comando)) {
82    // Lo que manda es lo que respondio GitHub: si dice que se unio, se unio, aunque otro comando encadenado despues
83    // (borrar la rama, que GitHub ya borro) haga terminar la orden con error (#122).
84    const unido = /successfully merged/i.test(salida) || (!conError && /\btrue\b/.test(salida))
85    lecturas.push({ tipo: 'paso', paso: 'union', ok: unido, detalle: corto(salida) })
86  } else if (/pulls\/\d+(?![\d/])/.test(comando) && /\.merged\b/.test(comando) && /\btrue\b/.test(salida)) {
87    // Confirmarla despues con el estado del PR; sin unir todavia no dice nada.
88    lecturas.push({ tipo: 'paso', paso: 'union', ok: true, detalle: corto(salida) })
89  }
90  if (/issues\/\d+\/comments\b/.test(comando) && /[Mm]ensaje de uni[oó]n/.test(comando + salida)) {
91    lecturas.push({ tipo: 'paso', paso: 'union', ok: !conError, detalle: 'Mensaje de unión dejado en el PR' })
92  }
93  return lecturas.length === 0 ? [{ tipo: 'nada' }] : lecturas
94}
95
96/**
97 * La rama en que se trabaja la tarea N: la propia (`tarea/N-…`) o, en la nube, la que trae la sesion (`claude/…`): la
98 * skill `tarea` dice trabajar en esa, y ahi no se crea una `tarea/N-…` que el mod vea (licitaciones #110).
99 */
100export function ramaDeTarea(rama: string, numero: number): boolean {
101  const r = rama.trim()
102  return r.startsWith(`tarea/${numero}-`) || /^claude\/\S+$/.test(r)
103}
104
105/** Un bloqueo: la persona o un permiso no dejo hacer algo. */
106export function esBloqueo(texto: string): boolean {
107  return /permission|denied|doesn't want to proceed|rejected|rechaz|bloque/i.test(texto)
108}
109
110/** Los criterios de la seccion «Terminado cuando» del cuerpo de la tarea. */
111export function criterios(cuerpo: string): string[] {
112  const seccion = /##\s*Terminado cuando\s*\n([\s\S]*?)(?:\n##\s|$)/.exec(cuerpo)
113  if (seccion === null) {
114    return []
115  }
116  return (seccion[1] ?? '')
117    .split('\n')
118    .map(l => /^\s*[-*]\s+(?:\[[ x]\]\s*)?(.+)$/.exec(l)?.[1]?.trim())
119    .filter((c): c is string => c !== undefined && c !== '')
120}
121
122/** owner/repo desde la direccion de origin (https o ssh); null si no es de GitHub. */
123export function repositorio(origen: string): string | null {
124  return /github\.com[:/]([^/\s]+\/[^/\s]+?)(?:\.git)?\s*$/.exec(origen.trim())?.[1] ?? null
125}
126
127/** Cuantos pasos van hechos y cual sigue. */
128export function avance(tarea: Tarea): { hechos: number; total: number; siguiente: string | null } {
129  const hechos = PASOS.filter(p => tarea.pasos[p.id] === 'ok').length
130  const siguiente = PASOS.find(p => tarea.pasos[p.id] !== 'ok')?.nombre ?? null
131  return { hechos, total: PASOS.length, siguiente }
132}
133
134/**
135 * Lo que cambio entre dos estados de la tarea, en lineas para el chat: la app y la web de Claude no dibujan el panel, y
136 * Angel sigue las sesiones ahi. Una linea por criterio o paso que cambio; al empezar, una con la tarea; al terminar el
137 * cierre, una que lo dice. Nada si no cambio nada (redibujar no repite lineas).
138 */
139export function cambios(antes: Tarea | null, despues: Tarea): string[] {
140  const t = `Tarea #${despues.numero}`
141  if (antes === null || antes.numero !== despues.numero) {
142    const titulo = despues.titulo !== '' ? ` · ${despues.titulo}` : ''
143    const criterios = despues.criterios.length > 0 ? `${despues.criterios.length} criterios y ` : ''
144    return [`📋 ${t}${titulo} · ${criterios}${PASOS.length} pasos de cierre`]
145  }
146  const lineas: string[] = []
147  const total = despues.criterios.length
148  const cumplidos = Object.values(despues.criteriosEstado).filter(x => x === 'hecho').length
149  despues.criterios.forEach((texto, i) => {
150    const estado = despues.criteriosEstado[i]
151    if (estado !== undefined && estado !== antes.criteriosEstado[i]) {
152      lineas.push(estado === 'hecho'
153        ? `✅ ${t} · Criterio ${i + 1} de ${total}: ${recorte(texto)} · criterios ${cumplidos}/${total}`
154        : `❌ ${t} · Criterio ${i + 1} de ${total} no se pudo: ${recorte(texto)} · criterios ${cumplidos}/${total}`)
155    }
156  })
157  const a = avance(despues)
158  for (const p of PASOS) {
159    const estado = despues.pasos[p.id]
160    if (estado !== undefined && estado !== antes.pasos[p.id]) {
161      lineas.push(estado === 'ok' ? `✅ ${t} · ${p.nombre} · cierre ${a.hechos}/${a.total}` : `❌ ${t} · ${p.nombre}: no se logró · cierre ${a.hechos}/${a.total}`)
162    }
163  }
164  if (a.hechos === a.total && avance(antes).hechos < a.total) {
165    lineas.push(`🏁 ${t}: cierre completo`)
166  }
167  return lineas
168}
169
170/** Un criterio largo, cortado para que la linea quepa en el celular. */
171function recorte(texto: string): string {
172  return texto.length > 90 ? `${texto.slice(0, 89)}…` : texto
173}
174
175export function barra(hechos: number, total: number): string {
176  return '▰'.repeat(hechos) + '▱'.repeat(Math.max(0, total - hechos))
177}
178
179/** El reporte para corregir CLAUDE.md: que se esperaba, que paso y que cambiar. Sin claves: solo el texto ya visto. */
180export function reporte(tarea: Tarea): string {
181  const lineas = [
182    `# Reporte de la tarea #${tarea.numero}: ${tarea.titulo}`,
183    '',
184    'Acciones que se esperaban y no se lograron. Cada una trae una propuesta para corregir CLAUDE.md (o AGENTS.md):',
185    'revisarla con Angel antes de cambiar nada.',
186    '',
187  ]
188  tarea.incidentes.forEach((inc, i) => {
189    lineas.push(`## ${i + 1}. ${inc.esperado} (${inc.cuando})`, '', `- **Qué pasó:** ${inc.paso}`)
190    if (inc.detalle !== '') {
191      lineas.push('', '```', inc.detalle, '```')
192    }
193    lineas.push('', `- **Propuesta para CLAUDE.md:** ${inc.propuesta}`, '')
194  })
195  return lineas.join('\n')
196}
197
198export function incidentePaso(paso: PasoId, detalle: string, cuando: string): Incidente {
199  const p = PASOS.find(x => x.id === paso)!
200  return { cuando, esperado: p.nombre, paso: `El paso «${p.nombre}» no se logró.`, detalle, propuesta: p.consejo }
201}
202
203export function corto(texto: string): string {
204  const limpio = texto.replace(/\r/g, '').trim()
205  return limpio.length > 600 ? limpio.slice(0, 600) + '…' : limpio
206}
207
208/**
209 * El avance como comentario del issue de la tarea (#4): la app de Claude en el celular no dibuja el panel, pero el issue
210 * se ve desde cualquier celular. Un solo comentario que se edita; sin casillas de tarea, para que no se marquen a mano.
211 */
212export function comentario(tarea: Tarea): string {
213  const a = avance(tarea)
214  const marca = (s: string | undefined): string => (s === 'ok' || s === 'hecho' ? '✅' : s === 'falla' || s === 'no_se_pudo' ? '❌' : '⬜')
215  const lineas = [
216    `### Avance de la tarea #${tarea.numero}`,
217    '',
218    `**Cierre** ${barra(a.hechos, a.total)} ${a.hechos}/${a.total} · ${a.siguiente !== null ? `sigue: ${a.siguiente}` : '🏁 cierre completo'}`,
219    '',
220    ...PASOS.map(p => `${marca(tarea.pasos[p.id])} ${p.nombre}  `),
221  ]
222  if (tarea.criterios.length > 0) {
223    const hechos = Object.values(tarea.criteriosEstado).filter(x => x === 'hecho').length
224    lineas.push('', `**Terminado cuando** (${hechos}/${tarea.criterios.length})`, '')
225    tarea.criterios.forEach((c, i) => lineas.push(`${marca(tarea.criteriosEstado[i])} ${i + 1}. ${c}  `))
226  }
227  if (tarea.incidentes.length > 0) {
228    const n = tarea.incidentes.length
229    lineas.push('', `⚠️ ${n} incidente${n === 1 ? '' : 's'}: \`${tarea.reporte ?? 'reporte sin escribir'}\``)
230  }
231  lineas.push('', '---', '_Se actualiza solo con el mod tarea-progreso de Claude Code._')
232  return lineas.join('\n')
233}
234
types/index.d.ts 33 lines
1export type PasoId = 'rama' | 'plan' | 'pruebas' | 'commit' | 'pr' | 'consumo' | 'ci' | 'union'
2
3/** Una accion que se esperaba y no se logro, con la propuesta para corregir CLAUDE.md. */
4export type Incidente = {
5  cuando: string
6  esperado: string
7  paso: string
8  detalle: string
9  propuesta: string
10}
11
12/** La tarea en curso: el issue de GitHub, sus criterios de «Terminado cuando» y los pasos del cierre. */
13export type Tarea = {
14  numero: number
15  titulo: string
16  repo: string | null
17  criterios: string[]
18  /** Por criterio (indice): 'hecho' o 'no_se_pudo'; los que faltan, pendientes. */
19  criteriosEstado: Record<number, 'hecho' | 'no_se_pudo'>
20  pasos: Partial<Record<PasoId, 'ok' | 'falla'>>
21  incidentes: Incidente[]
22  /** Donde quedo el reporte, cuando hay incidentes. */
23  reporte: string | null
24  /** El comentario del issue con el avance (#4), una vez creado; las tareas guardadas antes no lo traen. */
25  comentario?: number | null
26}
27
28declare module 'claude-code' {
29  interface PluginState {
30    'tarea-progreso': { tarea: Tarea | null }
31  }
32}
33