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

Mods de Claude Code para los proyectos de Angel (Vremia).
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.
/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./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./progreso dice por qué, con el motivo que da Claude Code.gh con permiso para comentar; si no lo tiene, todo lo demás sigue igual. Apagado el seguimiento, no se escribe.En el .claude/settings.json del proyecto:
{
"extraKnownMarketplaces": {
"claude-mods": { "source": { "source": "github", "repo": "LordMordelon/claude-mods" } }
},
"enabledPlugins": { "tarea-progreso@claude-mods": true }
}
claude plugin validate tarea-progreso
claude plugin test tarea-progresohooks/register.tsx 509 lines1// 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}
509hooks/logica.ts 234 lines1// 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}
234types/index.d.ts 33 lines1export 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