Mide, gestiona y controla el trabajo, y el control escucha lo medido: el panel y la orden del próximo despertar salen de la foto del index (cuellos de botella…

Un mod (plugin de hooks) de Claude Code que abre un panel dentro de la sesión y responde a una pregunta que el costo por token no contesta: ¿cuánto trabajo real salió de lo que se gastó?
Cruza tres fuentes que ya están en la máquina —las transcripciones locales de Claude Code, git y gh— y no envía nada a ningún servicio propio.
Foto de las 15:04 · hace 40 s · plataforma
Cuellos de botella · 2 (1 de gravedad alta)
ALTA · GitHub rechazó la credencial declarada (401): issues y despliegue quedan sin medir hasta renovarla.
MEDIA · Pieza terminada sin integrar: agy (agy/12) lleva 3 commits por delante de origin/main, árbol limpio, esperando hace 2 h 10 min.
La orden del próximo despertar · decidida 15:04 · por 1 señal del index
Orden: terminar antes que empezar.
1. Parar lo que dependa de GitHub. Pedir la credencial al dueño ahora, con la prueba (código HTTP y hora). Seguir sólo con lo que no la necesite.
porque: GitHub respondió 401 a la credencial declarada, medido el 2026-05-28 20:20 UTC
Después, y sólo con lo anterior resuelto: la issue #346, paso «retirar el botón».
Quién está en qué, medido · 2 de 3 árboles con trabajo vivo
■ terminada, sin integrar hace 2 h 10 min (aprox.: desde su último commit) · agy · #12 · agy/12 · 0 ficheros sin confirmar · 3 commits por delante
● trabajando · codex · #34 · codex/34 · 3 ficheros sin confirmar, el más reciente hace 12 min · 0 commits por delante
declarado, caducado: la #77 figura en manos de Codex y ya no está abierta en GitHub
Flujo
Issues abiertas: 15; 6 de 13 no pueden avanzar solas (1 por espera declarada, 5 por etiqueta de bloqueo, 0 de peso XL sin partir) · cerradas hoy: 2 · GitHub, en esta foto
Tiempo de entrega: la mitad de las construidas, menos de 2,0 d; 85 de cada 100, menos de 7,5 d · n = 151 issues construidas · cálculo diario de las 12:04
Sin integrar: 62 commits escritos y sin llegar a origin/main; 22 de hoy, el más viejo de hoy hace 3 h 10 min · n = 14 ramas miradas
Hueco desde el último empuje: 43 min · P95 = 52 min, n = 211
Cierres en bloque: 115 de 201 cierres, en 12 bloques · cálculo diario de las 12:04
Coordinación
Incidentes de hoy: 3; 2 de 3 son de una causa que se repite · n = 2 días de registro
La sesión · 5 h 47 min · medida 15:05
Presupuesto 200 USD ████████████████████░░░░ quedan 36.73 USD (gastado el 82 % de 200 USD)
Tanque de contexto ███████████████████░░░░░ queda libre el 21 % · ocupados 786k de 1.0M
≈ 0,8 USD por issue construida, acumulado (≈ 114 USD de la factura repartidos a este proyecto ÷ 151 issues construidas). Subestima: sólo cuenta a Claude, no a los otros agentes
El panel no calcula: lee. Todo lo de arriba menos «La sesión» sale de index.json y diario.json, que el guion del index refresca solo. El panel los relee cada minuto. Si la foto tiene más de diez minutos, la primera línea lo dice en rojo y no se muestra nada como vigente; si el guion no está instalado, dice cómo instalarlo y enseña sólo lo declarado y la sesión.
La unidad de medida es simple: issues cerradas por semana frente a lo que costó cerrarlas. Para que el tablero la pueda calcular sobre tu repositorio hacen falta estas cinco cosas.
1. Trabaja con issues. Cada tarea es una issue de GitHub en el repositorio. Lo que no es una issue no se cuenta. Una issue que se descarta se cierra como «not planned»: el tablero la separa de las hechas.
2. Abre Claude Code dentro del repositorio. El mod toma el repositorio del remoto origin (git remote get-url origin) y el consumo, de las transcripciones de Claude Code de esa carpeta. Si trabajas con varios repositorios, cada carpeta es un proyecto distinto y la factura se reparte entre todos.
3. Deja gh autenticado con una cuenta que pueda leer las issues: gh auth login. Si usas varias cuentas, el mod pide el token de la cuenta dueña del repositorio (gh auth token --user <dueño>) sin cambiar tu cuenta activa; con la opción cuentaGitHub eliges otra.
4. Instala el mod y dile cuánto pagas.
/plugin marketplace add cherrera0001/claude-code-mod-consumo
/plugin install consumo@consumo-local
Al instalar pide facturaMensualUsd (lo que pagas al mes por Claude) y presupuestoUsd (cuánto quieres gastar por sesión). Reinicia Claude Code.
5. Mide. /consumo avance recorre las transcripciones y las issues y pinta, semana por semana, cuántas se cerraron y cuánto de la factura se llevó el proyecto. Tarda medio minuto la primera vez; después guarda la última medición y la enseña al abrir la sesión.
Para que el control sepa quién hace qué, basta una convención y un fichero:
agente/número-de-issue (codex/344, agy/345), idealmente en su propio git worktree. El mod deduce de ahí quién es, en qué issue está y hace cuánto fue su última actividad.git no puede saber —que un agente se quedó sin cuota, o a quién se reasignó una issue— se escribe en .claude/orquestacion.json, en la raíz de tu repositorio. Hay una plantilla en ejemplos/orquestacion.json.En session.start y en cada despertar del bucle (/loop), antes de que nadie empiece, el hook:
gh que el repositorio declara en herramientas.gh, por ejemplo scripts/gh-acme.sh), qué commits de main nombran cada una, la rama agente/número de cada trabajador externo y .claude/orquestacion.json.P-*, decision-humana) no se asigna: queda «espera al dueño». Una épica no lleva modelo.peso:XS, peso:S, peso:M, peso:L o peso:XL. Sólo si no hay etiqueta deduce del texto, un punto por cada sí: más de una capa; migración, RLS, permisos, sesión o datos personales; la causa no está escrita; hace falta navegador o la suite de punta a punta; toca producción o una puerta. La deducción por palabras se pasa (da XL a issues de peso M): la etiqueta es de quien leyó la issue. Lo declarado manda sobre las dos cuando contesta las cinco preguntas. Una issue con la etiqueta bloqueada queda en «espera al dueño», salvo que declarado[n] diga algo de ella..claude/orquestacion.json:| Puntos | Peso | Esfuerzo | Quién lo hace |
|---|---|---|---|
| 0 | XS | bajo | esta sesión |
| 1 | S | medio | esta sesión |
| 2 | M | alto | esta sesión, o Agy si esta sesión está desplegando |
| 3 | L | muy alto | Claude construye; Agy o Codex, el que esté libre, revisa en sólo lectura |
| 4–5 | XL | máximo, con plan antes | no se asigna entera: se parte en pesos L o M |
El control no asigna modelo: el modelo es el de la sesión y se lee. El dial es el esfuerzo.
scheduled-trigger), al modelo no le llega el prompt del bucle: le llega la issue que tiene esta sesión, su primer paso sin hacer, el criterio y los archivos. El paso no se inventa: sale de declarado[n].pasos del fichero, y alguien lo marca hecho; el hook no lo da por hecho. Sin pasos o con todos hechos, la orden es parar: no se recorre el tablero ni se abren issues. Si la issue tiene espera escrito, la orden es la de la siguiente de esta sesión que no espere; sólo si todas esperan se para, con el motivo. Si el control falla, entra el prompt original: un despertar no se pierde. La misma orden, en pocas líneas, va en el prompt de sistema.Reglas que el control no negocia:
declarado[n].espera escrito sigue siendo de su trabajador y sigue a la vista, pero no lo retiene (ni a su revisor): el control le da la siguiente de la cola, y la cola no se queda entera detrás de una credencial que falta. Tampoco pasa a «por retomar» por inactividad. Al borrar espera, vuelve a ser la primera de su trabajador.agente/número y sin nada a medias pasa a «por retomar», y el siguiente despertar se la da al siguiente que esté libre.fuera no recibe nada, y el motivo queda a la vista.<raíz>-agy, rama agy/<número>; Codex en <raíz>-codex, rama codex/<número>. Reciben un encargo cerrado: número, criterio de aceptación y archivos que no pueden tocar.main, migrar producción y desplegar el API no se delegan.herramientas.candado (por ejemplo .suite.lock) está tomado, el control lo dice y no lanza ninguna suite pesada.La orden del próximo despertar ya no sale sólo del fichero. orden(estado, señales) recibe los cuellos de botella de la foto vigente y aplica una regla: terminar antes que empezar. Manda la primera que aplique, en este orden, y la orden lista hasta tres:
| # | Señal | Orden |
|---|---|---|
| 1 | Credencial rechazada | Parar lo que dependa de GitHub. Pedir la credencial al dueño ahora, con la prueba (código HTTP y hora). Seguir sólo con lo que no la necesite |
| 2 | Remoto movido bajo un árbol de esta sesión con trabajo | Antes de verificar o empujar: traer el remoto y volver a medir |
| 3 | Número de migración repetido o sin reserva que afecta a una issue de esta sesión | Reservar o renumerar antes de seguir; no encargar otra migración hasta que cuadre |
| 4 | Producción con código distinto del de la rama principal | Desplegar, en orden: migraciones, API, web |
| 5 | Pieza terminada sin integrar, de una asignación vigente, que espera más que el P95 de los huecos entre empujes | Integrar esa rama antes de construir nada nuevo |
| 6 | Trabajador externo con trabajo a medias y sin moverse | Preguntar al dueño por él; no reasignar |
| 7 | Ninguna | La de siempre: issue, primer paso sin hacer, criterio y archivos; o parar |
Cada orden lleva debajo la señal que la causó y su número («porque: pieza terminada hace 2 h 10 min; P95 = 52 min, n = 211»). Una señal que no afecta a esta sesión no manda aquí. Con la foto vieja no hay señales: el control no decide con lo que ya no sabe si es verdad. El router sigue sin entrada ni salida: las señales las pasa quien llama.
Al despertar del bucle le llega esa orden con las cifras de la foto. Al prompt de sistema va sólo la orden, y sólo se reescribe cuando cambian las señales que mandan, no cuando cambia la hora: reescribirla en cada foto rompería la caché del prompt. Si la decisión del control tiene más de diez minutos, se vuelve a tomar sola.
¿Sirve escuchar? Cada vez que cambian las señales que mandan, el control añade una línea a ~/.claude/consumo-index/decisiones.tsv (fecha, repositorio, señales, orden). El cálculo diario lo lee y dice cuántas señales hubo, cuánto tardó cada una en desaparecer y cuántas reaparecieron. Con menos de una semana de registro es sólo un conteo: la tendencia es NO MEDIDO.
La decisión llega a quien trabaja por dos sitios: el fichero, y una sección del prompt de sistema que repite la orden vigente. Los cuellos de botella del index no van al prompt de sistema (cada cambio rompería su caché y costaría tokens): van al panel, en una línea. El control sólo actúa en un repositorio que tenga .claude/orquestacion.json.
Reinicia Claude Code. El panel se abre solo en terminales anchas; en cualquier ancho, con /consumo.
Para desarrollarlo desde una copia local: claude --plugin-dir <carpeta de este repositorio>.
| Comando | Qué hace | ||
|---|---|---|---|
/consumo | Pinta el panel y devuelve el resumen de la sesión | ||
/consumo avance | Vuelve a medir issues cerradas y parte de la factura (tarda medio minuto), refresca el index y dice dónde quedó | ||
/consumo agentes | Ejecuta el control y responde con una frase por issue: quién, número, peso y qué hará el próximo despertar | ||
| `/consumo fuera <agy\ | codex> <motivo>` | Saca a ese trabajador y mueve su issue | |
| `/consumo tomar <número> <agy\ | codex\ | claude>` | Reasigna a mano y lo anota en la issue con una línea |
/consumo github | Relee issues y PR abiertos | ||
/consumo cobertura | Corre pytest --cov (sólo donde hay pytest) |
Se pide al instalar y se cambia en /plugin:
| Opción | Para qué | Por defecto |
|---|---|---|
facturaMensualUsd | Lo que pagas al mes; se reparte entre los proyectos de la máquina | 238 (un ejemplo: pon la tuya) |
presupuestoUsd | Presupuesto de la sesión, para la barra y la autonomía | 200 |
repo | dueño/nombre del repositorio de issues | el remoto origin |
cuentaGitHub | Cuenta de gh con la que se consulta | la dueña del repositorio |
En la raíz del repositorio de trabajo hay dos ficheros, y sólo uno se versiona:
| Fichero | Quién lo escribe | Se versiona | Qué lleva |
|---|---|---|---|
.claude/orquestacion.json | tú | sí | cola, fuera, declarado, no_tocar, produccion, herramientas, migraciones |
.claude/orquestacion.local.json | el control | no | asignaciones, clasificacion, router, reasignado |
Añade a tu .gitignore: .claude/orquestacion.local.json. El control no toca el fichero versionado: un árbol sucio no se despliega, y una decisión que cambia en cada despertar no es historia del repositorio. La única excepción es una orden tuya, /consumo fuera, que declara en fuera quién no recibe nada.
Lo que tú escribes, plantilla en ejemplos/orquestacion.json:
| Clave | Qué es |
|---|---|
cola | El orden en que se toman las issues |
fuera | Trabajador → motivo |
declarado | Por issue: clase, falta, solo_sesion, las cinco respuestas del peso, y lo que arma la orden: pasos (lista ordenada, cada uno con hecho), criterio, archivos y espera |
no_tocar | Archivos que un trabajador externo no puede tocar |
produccion | salud: la URL de /health de la que sale la ficha pública. rutas: lo que se despliega, para decidir si entre lo desplegado y la principal cambió código |
herramientas | Lo propio de tu repositorio, con rutas relativas a su raíz: gh (el envoltorio con que se habla con GitHub; .ps1 se lanza con PowerShell y .sh con bash), candado (el fichero del candado de suites), migraciones (la carpeta de migraciones; por defecto db/migrations), incidentes (el registro de incidentes; por defecto .claude/incidentes.tsv), dias_rama_antigua (por defecto 14) y stash_revisados (mensajes de stash que ya no avisan) |
migraciones | La tabla de reservas de números de migración (ver «El index vivo») |
El mod no trae nombres de ningún producto: si herramientas.gh falta, lee las issues con la credencial que gh guarda para la cuenta dueña del repositorio, y lo dice.
El fichero local sólo se reescribe cuando la decisión cambia. Si tu fichero versionado todavía trae asignaciones de una versión anterior, el control las respeta hasta que exista el local; después puedes borrarlas del versionado.
El index se actualiza solo, sin modelo, sin tokens y sin ninguna sesión de Claude abierta. Lo escribe un guion de Node estándar, herramientas/indice.mjs, que sólo usa git en lectura, el disco y, como mucho, cuatro peticiones HTTP de 5 segundos que nunca lo hacen fallar.
node herramientas/instalar.mjs --raiz /ruta/a/plataforma --credencial .env:GITHUB_TOKEN
node herramientas/instalar.mjs --desinstalar
--raiz se repite para varios repositorios; --cada 2 son los minutos de la tarea; --credencial, --migraciones y --candado valen para la --raiz que tienen delante (antes de la primera, para todas); --simular dice lo que haría sin hacerlo. El instalador:
indice.mjs y diario.mjs a ~/.claude/consumo-index/ y escribe ahí repositorios.json (de la credencial guarda el nombre del fichero y el de la variable; el valor, nunca);post-commit, post-merge, post-checkout, post-rewrite) en el directorio común del repositorio, así valen para todos sus worktrees. Lanzan el guion en segundo plano y no bloquean ni hacen fallar a git. Un hook que ya existe no se pisa: se le añade un bloque entre # >>> consumo-index y # <<< consumo-index, y desinstalar quita sólo ese bloque y lo deja idéntico byte a byte. Respeta core.hooksPath y lo dice. Un hook existente que no sea de sh no se toca;schtasks (nombre fijo consumo-index, lanzada con wscript y un .vbs para que no parpadee ninguna ventana); en Linux y macOS imprime la línea de crontab y no la instala;<repositorio>/.git/consumo/PIZARRA.md».--desinstalar quita los bloques de los hooks, la tarea, la pizarra y los ficheros copiados, y dice qué hizo en cada caso. Con una sesión abierta, el mod lanza el mismo guion al empezar y cada dos minutos, y la tercera pregunta del panel añade una línea con el primer cuello de botella y la ruta del index. Si el guion no está instalado, el panel dice cómo instalarlo y el mod deja al medir el index de antes (más abajo).
Por cada repositorio, en unos segundos (51 árboles, 3 s):
origin/main, cuántos ficheros sin confirmar y la hora del más reciente, y si esa rama tiene entradas en el stash. Los árboles de agentes efímeros (.claude/worktrees/) se resumen; sólo salen uno a uno los que tuvieron actividad en las últimas 2 horas.agente/número; el árbol principal es «sesión principal».origin/main (total, de hoy, por rama y el más viejo); los empujes de hoy y el hueco desde el último, frente al percentil 95 de los huecos del reflog de origin/main de ese clon (con su n; con menos de 40 huecos no hay P95 ni aviso). El umbral se calcula una vez al día y se guarda en umbrales.json: el refresco sólo compara.git ls-remote origin refs/heads/main frente al origin/main local. Nunca git fetch: el guion no ejecuta ninguna orden de git que escriba, y git status va con --no-optional-locks para no competir por el index.lock del árbol de otro agente.origin/main, en cada rama con commits por delante y en los ficheros sin confirmar de cada árbol, que es donde un número aparece primero. De ahí sale el siguiente número libre, medido. "migraciones": {
"_regla": "texto libre",
"0087": { "issue": 12, "quien": "agente backend", "estado": "escrita, sin integrar", "fichero": "0087_nombre.sql" },
"0089": { "issue": 34, "quien": "agente backend", "estado": "reservada" },
"siguiente_libre": "0090"
}
fecha hora causa coste_min deteccion recurso issue frase; la frase no se lee).main. Sin credencial, todo eso es NO MEDIDO.produccion.salud: commit, salud y si es el de origin/main.Lo que no se pudo medir queda en la página como NO MEDIDO, con su motivo. No se rellena con ceros.
Lo que no cabe en un refresco lo hace herramientas/diario.mjs, también sin modelo, como mucho una vez al día: lee todas las issues del repositorio (número, fechas, estado y etiquetas; nunca títulos) con la credencial declarada, y el historial de git en sólo lectura, y guarda en diario.json:
El refresco lee el diario.json que haya y dice su fecha. Si falta o tiene más de 20 horas, lo lanza él mismo en segundo plano y sin esperarlo (como mucho un intento cada media hora); a mano, node indice.mjs --diario. Sin credencial declarada no hay cálculo diario y esas secciones siguen en NO MEDIDO. El umbral de los huecos entre empujes sale de una sola función y se guarda en umbrales.json; el cálculo diario lo cita.
Van arriba del todo, en index.json (cuellos, con gravedad, que, desde y repositorio) y en la página.
| Gravedad | Cuello | Cuándo salta |
|---|---|---|
| alta | credencial rechazada | GitHub responde 401 a la credencial declarada |
| alta | migración | el mismo número con ficheros de nombre distinto en dos sitios (ramas, árboles o main), o un número en uso cuya reserva no existe o es de otra issue. Un solo aviso por número, con todo en una frase |
| alta | siguiente_libre que no es libre | el declarado es menor o igual que un número ya usado o reservado |
| alta | remoto movido | el remoto movió main y hay un árbol con trabajo debajo |
| alta | hueco de integración | más tiempo sin empujes que el P95 de los huecos, con commits de hoy esperando |
| alta | sin despliegue | el último commit de main lleva más de 10 min sin despliegue, o el despliegue falló |
| alta | producción | responde con problemas, o entre lo desplegado y main cambió código de lo que se despliega (git diff --quiet sobre produccion.rutas; por defecto, todo menos gobernanza/, docs/, .claude/ y los *.md). Otro commit con el mismo código no avisa: «al día: entre lo desplegado y la principal no cambió código» |
| media | producción no comparable | el commit desplegado no está en este clon |
| media o alta | issue en dos árboles | una issue con trabajo en dos árboles: es una pregunta («confirmar que el reparto es
hooks/register.tsx 1786 lines1import { atom, read, update } from 'claude-code'
2import type { Register } from 'claude-code'
3
4import type { Avance, Cobertura, Control, GitHub, Resumen, Sesion, Vivo } from '../types'
5import { COLA_INICIAL, EXTERNOS, TRABAJADORES, arbol, clasificar, conDecision, enrutar, frases, orden, pesar, resto, tabla } from './router'
6import type { Actividad, Asignacion, Estado, Issue, Orden, Salida, Trabajador } from './router'
7import { costoPorConstruida, leerFoto } from './panel'
8import type { Lectura } from '../types'
9import { comoDespertar, ficha, fichaDeProduccion, nombreDeArchivo, paginaDeTareas, registroDeDespertar, saludDe, urlDeSalud } from './indice'
10import type { Ficha } from './indice'
11
12// consumo 2.2 mide, gestiona y controla. El index lo escribe un guion sin modelo (herramientas/indice.mjs), que
13// también corre sin ninguna sesión abierta; aquí sólo se lanza y se lee. Si no está instalado, el mod deja al
14// medir el index de antes (una ficha por repositorio; ver indice.ts).
15// consumo 2.0 mide, gestiona y controla. Mide como antes. Gestiona la cola y quién tiene cada issue. Controla:
16// en session.start y en cada despertar del bucle asigna, reasigna o se detiene, y lo escribe en
17// .claude/orquestacion.json del repositorio de trabajo antes de que nadie empiece (la decisión vive en router.ts).
18// La parte que mide se lee como el indicador de combustible de un vehículo: cuánto tanque de contexto
19// queda, cuánto presupuesto va gastado, a qué ritmo se gasta y cuánta autonomía da; qué tarea se está
20// construyendo, qué espera el bucle y qué queda pendiente en GitHub. Solo lee; ejecuta el resumidor en
21// Python (embebido abajo, por stdin), `gh` para GitHub y, a pedido, pytest con cobertura.
22
23const PANE = 'consumo'
24// Se piden hasta TOPE_GITHUB issues y PR para poder decir cuántos hay; en el tablero caben los primeros.
25const TOPE_GITHUB = 200
26const VISIBLES_GITHUB = 8
27
28function cuantos(n: number): string {
29 return n >= TOPE_GITHUB ? `${n}+` : String(n)
30}
31
32const resumen = atom({ plugin: 'consumo', key: 'resumen' } as const, null as Resumen | null)
33const cobertura = atom({ plugin: 'consumo', key: 'cobertura' } as const, {
34 total: null,
35 estado: 'sin-dato',
36 cuando: null,
37 nota: '',
38} as Cobertura)
39const sesion = atom({ plugin: 'consumo', key: 'sesion' } as const, {
40 usdMotor: null,
41 contextoPct: null,
42 contextoTokens: null,
43 ventana: null,
44 inicio: null,
45 actualizado: null,
46 error: '',
47} as Sesion)
48const github = atom({ plugin: 'consumo', key: 'github' } as const, {
49 repo: '',
50 cuando: null,
51 issues: [],
52 prs: [],
53 error: '',
54} as GitHub)
55// Avance real: issues del repositorio cerradas por semana y parte de la factura mensual que consume el proyecto.
56const avance = atom({ plugin: 'consumo', key: 'avance' } as const, {
57 estado: 'sin-dato',
58 cuando: null,
59 error: '',
60 desde: '',
61 factura: 0,
62 proyecto: null,
63 repo: null,
64 semanas: [],
65 reparto: [],
66 index: '',
67} as Avance)
68
69// El control: la última decisión del router, en frases. Es lo que pinta la tercera pregunta del panel y lo
70// que el prompt de sistema le repite a quien despierta.
71const control = atom({ plugin: 'consumo', key: 'control' } as const, {
72 cuando: null,
73 activo: false,
74 github: '',
75 candado: '',
76 frases: [],
77 resto: [],
78 avisos: [],
79 orden: '',
80 ordenPrompt: '',
81 firma: '',
82 causas: 0,
83} as Control)
84
85const SIN_LECTURA: Lectura = { instalado: false, vigente: false, foto_ms: null, repositorio: null, senales: [], abiertas: null, fuera: [], caducadas: [], construidas: null, primera_alta: null, foto: '', vieja: '', cuellos: [], cuellos_resto: '', quien_titulo: '', quien: [], declarado: [], flujo: [], coordinacion: [] }
86
87// El index vivo: lo que dejó la última corrida del guion sin modelo. Va al panel, nunca al prompt de sistema:
88// cada cambio rompería su caché.
89const vivo = atom({ plugin: 'consumo', key: 'vivo' } as const, {
90 instalado: null,
91 carpeta: '',
92 cuello: '',
93 cuellos: 0,
94 altas: [],
95 cuando: null,
96 lectura: SIN_LECTURA,
97} as Vivo)
98
99// El resumidor, tal cual está en hooks/resumen_transcripcion.py; se ejecuta por stdin porque el módulo
100// no conoce su propia carpeta (`options` trae solo la configuración del usuario).
101const RESUMIDOR = `
102from __future__ import annotations
103
104import glob
105import json
106import os
107import sys
108from datetime import datetime, timezone
109
110sys.stdout.reconfigure(encoding="utf-8", errors="replace")
111
112PRECIOS = {
113 "claude-fable-5-1": (10.0, 50.0),
114 "claude-opus-5-5": (4.0, 20.0),
115 "claude-sonnet-5-5": (2.0, 10.0),
116 "claude-haiku-4-5": (1.0, 5.0),
117}
118CAMPOS = ("input_tokens", "output_tokens", "cache_read_input_tokens", "cache_creation_input_tokens")
119MINUTOS_RITMO = 60
120ANCHO_CUBO = 5
121
122
123def precio(modelo):
124 for clave, par in PRECIOS.items():
125 if modelo.startswith(clave):
126 return par
127 return (4.0, 20.0)
128
129
130def usd(modelo, c):
131 entrada, salida = precio(modelo)
132 return (c["input_tokens"] * entrada + c["output_tokens"] * salida
133 + c["cache_read_input_tokens"] * entrada * 0.1 + c["cache_creation_input_tokens"] * entrada * 1.25) / 1e6
134
135
136def vacio():
137 return {k: 0 for k in CAMPOS} | {"llamadas": 0}
138
139
140def sumar(destino, u):
141 for k in CAMPOS:
142 destino[k] += int(u.get(k) or 0)
143 destino["llamadas"] += 1
144
145
146def instante(r):
147 t = r.get("timestamp")
148 if not isinstance(t, str):
149 return None
150 try:
151 return datetime.fromisoformat(t.replace("Z", "+00:00")).timestamp()
152 except ValueError:
153 return None
154
155
156def titulo_de(contenido):
157 if isinstance(contenido, str):
158 texto = contenido
159 elif isinstance(contenido, list):
160 if any(isinstance(b, dict) and b.get("type") == "tool_result" for b in contenido):
161 return None
162 texto = " ".join(b.get("text", "") for b in contenido if isinstance(b, dict) and b.get("type") == "text")
163 else:
164 return None
165 texto = " ".join(texto.split())
166 if not texto or texto.startswith("<"):
167 return None
168 return texto[:70]
169
170
171class Lectura:
172 def __init__(self):
173 self.por_modelo = {}
174 self.tareas = {}
175 self.orden = []
176 self.herramientas = {}
177 self.respuestas = []
178 self.despertador = None
179 self.ultimo_instante = None
180 self.primer_instante = None
181
182 def tarea(self, titulo, inicio):
183 if titulo not in self.tareas:
184 self.tareas[titulo] = vacio() | {"veces": 0, "usd": 0.0, "modelos": {}, "inicio": inicio, "fin": inicio}
185 self.orden.append(titulo)
186 return self.tareas[titulo]
187
188 def leer(self, ruta, etiqueta):
189 actual = etiqueta
190 encontradas = 0
191 mensajes = {}
192 bloques = set()
193 if etiqueta is not None:
194 self.tarea(etiqueta, None)["veces"] += 1
195 with open(ruta, encoding="utf-8", errors="replace") as f:
196 for linea in f:
197 try:
198 r = json.loads(linea)
199 except Exception:
200 continue
201 tipo = r.get("type")
202 m = r.get("message") or {}
203 t_r = instante(r)
204 if tipo == "user" and etiqueta is None and not r.get("isMeta"):
205 t = titulo_de(m.get("content"))
206 if t:
207 actual = t
208 fila = self.tarea(t, t_r)
209 fila["veces"] += 1
210 if fila["inicio"] is None:
211 fila["inicio"] = t_r
212 continue
213 if tipo != "assistant":
214 continue
215 u = m.get("usage") or {}
216 modelo = m.get("model") or "?"
217 if u:
218 # Un mismo mensaje sale en varias lineas (una por bloque) y todas repiten su uso:
219 # se cuenta una vez, con el ultimo valor, en la tarea donde empezo.
220 clave = m.get("id") or r.get("uuid")
221 previo = mensajes.get(clave)
222 mensajes[clave] = (modelo, u, t_r if t_r is not None else (previo[2] if previo else None),
223 previo[3] if previo else actual)
224 for b in m.get("content") or []:
225 if not (isinstance(b, dict) and b.get("type") == "tool_use"):
226 continue
227 if b.get("id") in bloques:
228 continue
229 bloques.add(b.get("id"))
230 nombre = str(b.get("name"))
231 self.herramientas[nombre] = self.herramientas.get(nombre, 0) + 1
232 if nombre == "ScheduleWakeup" and etiqueta is None and isinstance(b.get("input"), dict):
233 e = b["input"]
234 self.despertador = {"razon": str(e.get("reason") or "")[:120], "segundos": e.get("delaySeconds"),
235 "parar": bool(e.get("stop")), "noop": bool(e.get("noop")), "cuando": t_r}
236 for modelo, u, t_r, tarea in mensajes.values():
237 encontradas += 1
238 conteo = {k: int(u.get(k) or 0) for k in CAMPOS}
239 self.por_modelo.setdefault(modelo, vacio())
240 sumar(self.por_modelo[modelo], u)
241 costo = usd(modelo, conteo)
242 if t_r is not None:
243 self.respuestas.append((t_r, costo))
244 self.ultimo_instante = max(self.ultimo_instante or t_r, t_r)
245 self.primer_instante = min(self.primer_instante or t_r, t_r)
246 if tarea is not None:
247 fila = self.tarea(tarea, t_r)
248 sumar(fila, u)
249 fila["usd"] += costo
250 fila["modelos"][modelo] = fila["modelos"].get(modelo, 0) + 1
251 if t_r is not None:
252 fila["fin"] = max(fila["fin"] or t_r, t_r)
253 return encontradas
254
255
256def rutas_de_la_sesion(sesion, cwd):
257 codificada = "".join(c if (c.isascii() and c.isalnum()) else "-" for c in cwd)
258 base = os.path.join(os.environ.get("USERPROFILE") or os.path.expanduser("~"), ".claude", "projects", codificada)
259 return os.path.join(base, sesion + ".jsonl"), os.path.join(base, sesion, "subagents")
260
261
262def etiqueta_de_subagente(ruta):
263 nombre = os.path.basename(ruta)
264 ident = nombre.replace("agent-", "").replace(".jsonl", "")[:8]
265 meta = ruta[:-len(".jsonl")] + ".meta.json"
266 try:
267 m = json.load(open(meta, encoding="utf-8"))
268 for clave in ("description", "agentType", "subagent_type", "name"):
269 if isinstance(m.get(clave), str) and m[clave].strip():
270 return "subagente " + " ".join(m[clave].split())[:40] + " (" + ident + ")"
271 except Exception:
272 pass
273 return "subagente " + ident
274
275
276def ritmo(respuestas, ahora):
277 cubos = [0.0] * (MINUTOS_RITMO // ANCHO_CUBO)
278 ultimos_30 = 0.0
279 for t, costo in respuestas:
280 hace = ahora - t
281 if hace < 0 or hace >= MINUTOS_RITMO * 60:
282 continue
283 cubos[len(cubos) - 1 - int(hace // (ANCHO_CUBO * 60))] += costo
284 if hace < 1800:
285 ultimos_30 += costo
286 return {"cubos_usd": [round(c, 2) for c in cubos], "ancho_cubo_min": ANCHO_CUBO,
287 "usd_ultimos_30_min": round(ultimos_30, 2), "usd_por_hora": round(ultimos_30 * 2, 2)}
288
289
290def main():
291 sesion = sys.argv[sys.argv.index("--sesion") + 1]
292 cwd = sys.argv[sys.argv.index("--cwd") + 1]
293 transcripcion, carpeta_sub = rutas_de_la_sesion(sesion, cwd)
294 # Una sesion recien abierta aun no tiene transcripcion: no es un error, es un resumen vacio.
295 hay = os.path.exists(transcripcion)
296 lectura = Lectura()
297 if hay:
298 lectura.leer(transcripcion, None)
299 subagentes = []
300 if os.path.isdir(carpeta_sub):
301 for ruta in sorted(glob.glob(os.path.join(carpeta_sub, "agent-*.jsonl"))):
302 etiqueta = etiqueta_de_subagente(ruta)
303 if lectura.leer(ruta, etiqueta) > 0:
304 subagentes.append(etiqueta)
305 else:
306 lectura.tareas.pop(etiqueta, None)
307 lectura.orden.remove(etiqueta)
308 ahora = datetime.now(timezone.utc).timestamp()
309 filas_modelo = [{"modelo": modelo, **c, "usd": round(usd(modelo, c), 2)} for modelo, c in lectura.por_modelo.items()]
310 filas_tarea = []
311 for t in lectura.orden:
312 c = lectura.tareas[t]
313 filas_tarea.append({
314 "titulo": t, "veces": c["veces"], "llamadas": c["llamadas"], "output_tokens": c["output_tokens"],
315 "cache_read_input_tokens": c["cache_read_input_tokens"], "cache_creation_input_tokens": c["cache_creation_input_tokens"],
316 "usd": round(c["usd"], 2), "modelo": max(c["modelos"], key=c["modelos"].get) if c["modelos"] else "?",
317 "inicio": c["inicio"], "fin": c["fin"],
318 "minutos": round((c["fin"] - c["inicio"]) / 60, 1) if c["inicio"] and c["fin"] else None,
319 })
320 total = vacio()
321 for c in lectura.por_modelo.values():
322 for k in CAMPOS:
323 total[k] += c[k]
324 total["llamadas"] += c["llamadas"]
325 principales = [f for f in filas_tarea if not f["titulo"].startswith("subagente ")]
326 actual = principales[-1] if principales else None
327 if actual is not None:
328 actual = dict(actual, hace_min=round((ahora - actual["fin"]) / 60, 1) if actual["fin"] else None)
329 desp = lectura.despertador
330 if desp and desp.get("cuando") and isinstance(desp.get("segundos"), (int, float)):
331 proximo = desp["cuando"] + float(desp["segundos"])
332 desp = dict(desp, proximo=proximo, faltan_min=round((proximo - ahora) / 60, 1))
333 salida = {
334 "transcripcion": os.path.basename(transcripcion),
335 "sin_transcripcion": not hay,
336 "ahora": ahora,
337 "inicio_sesion": lectura.primer_instante,
338 "ultima_respuesta": lectura.ultimo_instante,
339 "total": total | {"usd": round(sum(f["usd"] for f in filas_modelo), 2)},
340 "por_modelo": filas_modelo,
341 "por_tarea": filas_tarea,
342 "por_herramienta": dict(sorted(lectura.herramientas.items(), key=lambda kv: -kv[1])),
343 "subagentes": subagentes,
344 "actual": actual,
345 "despertador": desp,
346 "ritmo": ritmo(lectura.respuestas, ahora),
347 "nota_precios": "precio de lista; cache leida 0,1 y escrita 1,25 de la entrada (cache de 5 min)",
348 }
349 print(json.dumps(salida, ensure_ascii=False))
350
351
352main()
353`
354
355// La medición de avance, tal cual está en hooks/avance_proyecto.py; por stdin, por la misma razón.
356const AVANCE_PY = `
357from __future__ import annotations
358
359import glob
360import json
361import os
362import re
363import subprocess
364import sys
365import tempfile
366import time
367from datetime import datetime, timedelta, timezone
368
369sys.stdout.reconfigure(encoding="utf-8", errors="replace")
370
371# Avance real de un proyecto: que parte del consumo de Claude Code se lleva (para repartir la factura
372# mensual) y cuantas issues de su repositorio se cerraron con el. Solo lee: transcripciones locales y gh.
373# Fuente en ASCII y sin barras invertidas: va embebido en el modulo y entra por stdin.
374
375# Peso por millon de tokens (entrada, salida). Sirve para REPARTIR la factura entre proyectos; no es un precio.
376PESOS = (("fable", 10.0, 50.0), ("opus", 4.0, 20.0), ("sonnet", 2.0, 10.0), ("haiku", 1.0, 5.0))
377CADUCA_S = 20 * 60
378# Version de la forma de la salida: una cache escrita por otra version no se reutiliza.
379VERSION = 2
380FORMATO = "%Y-%m-%dT%H:%M:%S"
381
382
383def arg(nombre, defecto=""):
384 return sys.argv[sys.argv.index(nombre) + 1] if nombre in sys.argv else defecto
385
386
387def clave_de(ruta):
388 # La carpeta del proyecto en ~/.claude/projects: todo lo que no es alfanumerico ASCII pasa a "-".
389 # Los worktrees de agentes y de integracion cuentan en su proyecto.
390 plana = "".join(c if (c.isascii() and c.isalnum()) else "-" for c in ruta).lower()
391 return re.sub("(--claude-worktrees-|-worktrees-).*$", "", plana)
392
393
394def peso(modelo, u):
395 for clave, entrada, salida in PESOS:
396 if clave in modelo:
397 leida = int(u.get("cache_read_input_tokens") or 0) * entrada * 0.1 / 1e6
398 resto = (int(u.get("input_tokens") or 0) * entrada + int(u.get("output_tokens") or 0) * salida
399 + int(u.get("cache_creation_input_tokens") or 0) * entrada * 1.25) / 1e6
400 return leida + resto, leida
401 return 0.0, 0.0
402
403
404def lunes(iso):
405 d = datetime.fromisoformat(iso[:19] + "+00:00")
406 return (d - timedelta(days=d.weekday())).strftime("%Y-%m-%d")
407
408
409def nuevo():
410 return {"sesiones": 0, "prompts": 0, "llamadas": 0, "peso": 0.0, "peso_sub": 0.0, "peso_relectura": 0.0,
411 "despertares": 0, "vacios": 0, "agentes": 0, "semanas": {}, "raices": set()}
412
413
414def leer(ruta, desde_iso, p, es_sub, detalle):
415 mensajes = {}
416 vistos = set()
417 try:
418 f = open(ruta, "rb")
419 except OSError:
420 return False
421 with f:
422 for cruda in f:
423 if b'"usage"' not in cruda:
424 if not detalle or es_sub or b'"type":"user"' not in cruda or b'"tool_result"' in cruda:
425 continue
426 try:
427 r = json.loads(cruda)
428 except Exception:
429 continue
430 t = r.get("timestamp")
431 if not isinstance(t, str) or t < desde_iso:
432 continue
433 m = r.get("message")
434 if not isinstance(m, dict):
435 continue
436 if r.get("type") == "assistant" and isinstance(m.get("usage"), dict):
437 # un mismo mensaje sale en varias lineas (una por bloque): gana la ultima
438 mensajes[m.get("id") or r.get("uuid")] = (str(m.get("model") or "?"), m["usage"], t)
439 # la carpeta en que corria la sesion: de ahi sale la raiz del proyecto para el index
440 if not es_sub and isinstance(r.get("cwd"), str) and r.get("cwd"):
441 p["raices"].add(r["cwd"])
442 if not detalle:
443 continue
444 for b in m.get("content") or []:
445 if not (isinstance(b, dict) and b.get("type") == "tool_use") or b.get("id") in vistos:
446 continue
447 vistos.add(b.get("id"))
448 e = b.get("input") if isinstance(b.get("input"), dict) else {}
449 if b.get("name") == "ScheduleWakeup" and not e.get("stop"):
450 p["despertares"] += 1
451 p["vacios"] += 1 if e.get("noop") is True else 0
452 elif b.get("name") in ("Agent", "Task"):
453 p["agentes"] += 1
454 elif r.get("type") == "user" and detalle and not es_sub and not r.get("isMeta"):
455 c = m.get("content")
456 if isinstance(c, list):
457 c = next((b.get("text") or "" for b in c if isinstance(b, dict) and b.get("type") == "text"), "")
458 if isinstance(c, str) and c.strip() and not c.lstrip().startswith("<"):
459 p["prompts"] += 1
460 for modelo, u, t in mensajes.values():
461 w, leida = peso(modelo, u)
462 p["llamadas"] += 1
463 p["peso"] += w
464 p["peso_relectura"] += leida
465 if es_sub:
466 p["peso_sub"] += w
467 k = lunes(t)
468 p["semanas"][k] = p["semanas"].get(k, 0.0) + w
469 return bool(mensajes)
470
471
472def raiz_de(clave, p):
473 # La raiz del proyecto: la carpeta mas corta, de las que sus sesiones usaron, cuya clave es la del proyecto
474 # (un worktree comparte la clave y es mas largo). Con barras normales; vacia si ninguna coincide.
475 propias = sorted((c for c in p["raices"] if clave_de(c) == clave), key=lambda c: (len(c), c))
476 return propias[0].replace(os.sep, "/").rstrip("/") if propias else ""
477
478
479def repo_de(raiz):
480 try:
481 r = subprocess.run(["git", "-C", raiz, "remote", "get-url", "origin"], capture_output=True, text=True, timeout=15)
482 except Exception:
483 return ""
484 m = re.search("github[.]com[:/]+([^/]+)/([^/]+?)(?:[.]git)?/?$", r.stdout.strip())
485 return m.group(1) + "/" + m.group(2) if m else ""
486
487
488def issues_de(repo, cuenta):
489 # La cuenta es la configurada o, si no, la duena del repositorio; el token solo viaja en el entorno de gh.
490 env = dict(os.environ)
491 via = "cuenta activa de gh"
492 for usuario in (cuenta, repo.split("/")[0]):
493 if not usuario:
494 continue
495 try:
496 t = subprocess.run(["gh", "auth", "token", "--user", usuario], capture_output=True, text=True, timeout=20)
497 except Exception as exc:
498 return None, via, "gh no esta disponible: " + str(exc)[:80]
499 if t.returncode == 0 and t.stdout.strip():
500 env["GH_TOKEN"] = t.stdout.strip()
501 via = "cuenta " + usuario
502 break
503 try:
504 r = subprocess.run(["gh", "issue", "list", "--repo", repo, "--state", "all", "--limit", "3000",
505 "--json", "number,state,stateReason,createdAt,closedAt"],
506 capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=120, env=env)
507 except Exception as exc:
508 return None, via, str(exc)[:120]
509 if r.returncode != 0:
510 lineas = (r.stderr or "gh fallo").strip().splitlines()
511 return None, via, (lineas[-1] if lineas else "gh fallo")[:160]
512 try:
513 return json.loads(r.stdout), via, ""
514 except Exception:
515 return None, via, "gh no devolvio JSON"
516
517
518def main():
519 cwd = arg("--cwd", os.getcwd())
520 raiz = arg("--raiz", cwd)
521 dias = int(arg("--dias", "30"))
522 factura = float(arg("--factura", "238"))
523 repo = arg("--repo") or repo_de(raiz)
524 cuenta = arg("--cuenta")
525 mia = clave_de(cwd)
526 cache = os.path.join(tempfile.gettempdir(), "claude-consumo-avance-" + mia + ".json")
527 # --previa: la ultima medicion guardada, tenga la edad que tenga, para pintar algo mientras se mide.
528 previa = "--previa" in sys.argv
529 if previa or "--forzar" not in sys.argv:
530 try:
531 if previa or time.time() - os.path.getmtime(cache) < CADUCA_S:
532 previo = json.load(open(cache, encoding="utf-8"))
533 if (previo.get("version") == VERSION and previo.get("dias") == dias and previo.get("factura") == factura
534 and (previo.get("repo") or {}).get("nombre") == repo):
535 print(json.dumps(dict(previo, de_cache=True), ensure_ascii=False))
536 return
537 except Exception:
538 pass
539 if previa:
540 print("{}")
541 return
542
543 # La ventana empieza el lunes de la semana de hace --dias dias: semanas enteras, consumo e issues alineados.
544 ahora = datetime.now(timezone.utc)
545 corte = ahora - timedelta(days=dias)
546 desde = (corte - timedelta(days=corte.weekday())).replace(hour=0, minute=0, second=0, microsecond=0)
547 desde_iso = desde.strftime(FORMATO)
548 base = os.path.join(os.environ.get("USERPROFILE") or os.path.expanduser("~"), ".claude", "projects")
549 proyectos = {}
550 for nombre in (os.listdir(base) if os.path.isdir(base) else []):
551 carpeta = os.path.join(base, nombre)
552 if not os.path.isdir(carpeta):
553 continue
554 clave = clave_de(nombre)
555 p = proyectos.setdefault(clave, nuevo())
556 detalle = clave == mia
557 for ruta in glob.glob(os.path.join(carpeta, "*.jsonl")):
558 if os.path.getmtime(ruta) >= desde.timestamp() and leer(ruta, desde_iso, p, False, detalle):
559 p["sesiones"] += 1
560 for ruta in glob.glob(os.path.join(carpeta, "*", "subagents", "*.jsonl")):
561 if os.path.getmtime(ruta) >= desde.timestamp():
562 leer(ruta, desde_iso, p, True, detalle)
563
564 total = sum(p["peso"] for p in proyectos.values())
565 yo = proyectos.get(mia) or nuevo()
566 parte = yo["peso"] / total if total > 0 else 0.0
567 proyecto = {
568 "clave": mia, "sesiones": yo["sesiones"], "prompts": yo["prompts"], "llamadas": yo["llamadas"],
569 "pct_del_total": round(100 * parte, 1), "usd_factura": round(parte * factura, 2),
570 "pct_subagentes": round(100 * yo["peso_sub"] / yo["peso"], 1) if yo["peso"] > 0 else 0.0,
571 "pct_relectura": round(100 * yo["peso_relectura"] / yo["peso"], 1) if yo["peso"] > 0 else 0.0,
572 "despertares": yo["despertares"], "despertares_vacios": yo["vacios"], "agentes": yo["agentes"],
573 }
574 reparto = []
575 if total > 0:
576 for k, p in sorted(proyectos.items(), key=lambda kv: -kv[1]["peso"])[:5]:
577 if p["peso"] > 0:
578 reparto.append({"nombre": re.sub("^[a-z]--(code-)?", "", k) or k, "raiz": raiz_de(k, p),
579 "pct": round(100 * p["peso"] / total, 1), "usd": round(p["peso"] / total * factura, 2)})
580
581 info = {"nombre": repo, "via": "", "error": "", "abiertas": 0, "creadas": 0, "hechas": 0, "descartadas": 0,
582 "usd_por_issue": None}
583 issues = []
584 if not repo:
585 info["error"] = "este proyecto no tiene remoto de GitHub"
586 else:
587 lista, info["via"], info["error"] = issues_de(repo, cuenta)
588 issues = lista or []
589
590 semanas = []
591 d = desde
592 while d <= ahora:
593 ini = d.strftime(FORMATO)
594 fin = (d + timedelta(days=7)).strftime(FORMATO)
595 cerradas = [i for i in issues if i.get("closedAt") and ini <= i["closedAt"] < fin]
596 descartadas = sum(1 for i in cerradas if i.get("stateReason") == "NOT_PLANNED")
597 w = yo["semanas"].get(d.strftime("%Y-%m-%d"), 0.0)
598 semanas.append({
599 "lunes": d.strftime("%Y-%m-%d"),
600 "creadas": sum(1 for i in issues if ini <= (i.get("createdAt") or "") < fin),
601 "hechas": len(cerradas) - descartadas,
602 "descartadas": descartadas,
603 "abiertas_fin": sum(1 for i in issues if (i.get("createdAt") or "") < fin)
604 - sum(1 for i in issues if i.get("closedAt") and i["closedAt"] < fin),
605 "usd_factura": round(w / total * factura, 2) if total > 0 else 0.0,
606 "pct_del_proyecto": round(100 * w / yo["peso"], 1) if yo["peso"] > 0 else 0.0,
607 })
608 d += timedelta(days=7)
609 info["abiertas"] = sum(1 for i in issues if i.get("state") == "OPEN")
610 info["creadas"] = sum(s["creadas"] for s in semanas)
611 info["hechas"] = sum(s["hechas"] for s in semanas)
612 info["descartadas"] = sum(s["descartadas"] for s in semanas)
613 if info["hechas"] > 0 and not info["error"]:
614 info["usd_por_issue"] = round(proyecto["usd_factura"] / info["hechas"], 2)
615
616 salida = {
617 "version": VERSION,
618 # Donde el mod deja el index: en la carpeta del usuario, fuera de cualquier repositorio.
619 "carpeta_index": os.path.join(os.path.dirname(base), "consumo-index").replace(os.sep, "/"),
620 "ahora": ahora.timestamp(), "desde": desde.strftime("%Y-%m-%d"), "dias": dias, "factura": factura,
621 "proyectos_con_actividad": sum(1 for p in proyectos.values() if p["peso"] > 0),
622 "proyecto": proyecto, "repo": info, "semanas": semanas, "reparto": reparto, "de_cache": False,
623 }
624 cuerpo = json.dumps(salida, ensure_ascii=False)
625 if not info["error"]:
626 try:
627 with open(cache, "w", encoding="utf-8") as f:
628 f.write(cuerpo)
629 except OSError:
630 pass
631 print(cuerpo)
632
633
634main()
635`
636
637function miles(n: number): string {
638 if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M'
639 if (n >= 1_000) return Math.round(n / 1_000) + 'k'
640 return String(n)
641}
642
643function corto(modelo: string): string {
644 return modelo.replace(/^claude-/, '').replace(/-\d{8}$/, '')
645}
646
647function hora(ms: number | null | undefined): string {
648 if (ms === null || ms === undefined) return '—'
649 const d = new Date(ms)
650 const dos = (x: number) => String(x).padStart(2, '0')
651 return `${dos(d.getHours())}:${dos(d.getMinutes())}`
652}
653
654function duracion(min: number | null | undefined): string {
655 if (min === null || min === undefined || !isFinite(min)) return '—'
656 const total = Math.max(0, Math.round(min))
657 const h = Math.floor(total / 60)
658 const m = total % 60
659 return h > 0 ? `${h} h ${String(m).padStart(2, '0')} min` : `${m} min`
660}
661
662function barra(fraccion: number | null, ancho: number): string {
663 if (fraccion === null || !isFinite(fraccion)) return '░'.repeat(ancho)
664 const f = Math.min(1, Math.max(0, fraccion))
665 const llenos = Math.round(f * ancho)
666 return '█'.repeat(llenos) + '░'.repeat(ancho - llenos)
667}
668
669function tono(fraccion: number | null): string {
670 if (fraccion === null || !isFinite(fraccion)) return 'gray'
671 if (fraccion < 0.6) return 'green'
672 if (fraccion < 0.85) return 'yellow'
673 return 'red'
674}
675
676function chispas(valores: number[]): string {
677 const simbolos = '▁▂▃▄▅▆▇█'
678 const max = Math.max(0, ...valores)
679 if (max <= 0) return '▁'.repeat(valores.length)
680 return valores.map(v => simbolos[Math.min(7, Math.round((v / max) * 7))]).join('')
681}
682
683function numero(x: unknown, porDefecto: number): number {
684 return typeof x === 'number' && isFinite(x) ? x : porDefecto
685}
686
687function texto(x: unknown, porDefecto: string): string {
688 return typeof x === 'string' && x.trim() ? x.trim() : porDefecto
689}
690
691function fechaCorta(iso: string): string {
692 return /^\d{4}-\d{2}-\d{2}/.test(iso) ? `${iso.slice(8, 10)}-${iso.slice(5, 7)}` : '—'
693}
694
695async function pythonDe($: any, root: string): Promise<string> {
696 const venv = root.replace(/[\\/]+$/, '') + '/.venv/Scripts/python.exe'
697 return (await $.fs.exists(venv)) ? venv : 'python'
698}
699
700const barras = (ruta: string): string => ruta.replace(/\\/g, '/').replace(/\/+$/, '')
701const primeraLinea = (t: string): string => (t.split(/\r?\n/).find(l => l.trim()) ?? '').trim().slice(0, 160)
702
703async function correr($: any, argv: string[], cwd: string, timeoutMs = 30_000, env?: Record<string, string>): Promise<{ ok: boolean; stdout: string; stderr: string }> {
704 try {
705 const r = await $.process.run(argv, env ? { cwd, timeoutMs, env } : { cwd, timeoutMs })
706 return { ok: r.exitCode === 0, stdout: r.stdout ?? '', stderr: r.stderr ?? '' }
707 } catch (exc) {
708 return { ok: false, stdout: '', stderr: String(exc) }
709 }
710}
711
712// Los nombres con que nació el mod. Lo propio de cada repositorio se declara en `herramientas` de su
713// .claude/orquestacion.json; si la clave falta, estos valen SÓLO si el fichero existe en ese repositorio.
714const GH_SI_EXISTE = 'scripts/gh-vt.ps1'
715const CANDADO_SI_EXISTE = '.vt-suite.lock'
716
717/** Una ruta declarada sólo vale si es relativa al repositorio y no sale de él. */
718function rutaDelRepositorio(x: unknown): string {
719 if (typeof x !== 'string') return ''
720 const r = x.trim().replace(/\\/g, '/')
721 return r && !r.startsWith('/') && !/^[A-Za-z]:/.test(r) && !r.split('/').includes('..') ? r : ''
722}
723
724/** El envoltorio con que este repositorio habla con GitHub, o '' si no declara ninguno ni tiene el de siempre. */
725async function envoltorioDeGh($: any, raiz: string, estado: Estado): Promise<string> {
726 const declarado = rutaDelRepositorio(estado.herramientas?.gh)
727 if (declarado) return declarado
728 try {
729 return (await $.fs.exists(`${raiz}/${GH_SI_EXISTE}`)) ? GH_SI_EXISTE : ''
730 } catch {
731 return ''
732 }
733}
734
735// GitHub del producto sólo con el envoltorio del repositorio: su token y su identidad, comprobada por él.
736function ghDelProyecto(raiz: string, envoltorio: string, ...orden: string[]): string[] {
737 const guion = `${raiz}/${envoltorio}`
738 if (/\.ps1$/i.test(envoltorio)) return ['powershell', '-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', guion, ...orden]
739 if (/\.sh$/i.test(envoltorio)) return ['bash', guion, ...orden]
740 return [guion, ...orden]
741}
742
743// Dos ficheros. El VERSIONADO lo escribe una persona (cola, fuera, declarado, no_tocar, produccion) y el control
744// no lo toca: un árbol sucio no se despliega. El LOCAL lo escribe el control y no se versiona.
745const VERSIONADO = '.claude/orquestacion.json'
746const LOCAL = '.claude/orquestacion.local.json'
747const DEL_CONTROL = ['asignaciones', 'clasificacion', 'router', 'reasignado'] as const
748
749function loDelControl(estado: Estado): Estado {
750 const solo: Estado = {}
751 for (const clave of DEL_CONTROL) if (estado[clave] !== undefined) (solo as Record<string, unknown>)[clave] = estado[clave]
752 return solo
753}
754
755/**
756 * El estado que el control decide: lo declarado en el fichero versionado más lo suyo, del local. Si el local
757 * todavía no existe, vale lo que el versionado traiga de antes de la 2.1.1; desde ahí sólo se escribe el local.
758 */
759async function leerEstado($: any, raiz: string): Promise<{ estado: Estado; declarado: Estado; textoLocal: string }> {
760 let declarado: Estado = {}
761 try {
762 const leido = JSON.parse(String(await $.fs.read(`${raiz}/${VERSIONADO}`))) as unknown
763 if (leido && typeof leido === 'object' && !Array.isArray(leido)) declarado = leido as Estado
764 } catch {
765 declarado = {}
766 }
767 let textoLocal = ''
768 let local: Estado = {}
769 try {
770 if (await $.fs.exists(`${raiz}/${LOCAL}`)) {
771 textoLocal = String(await $.fs.read(`${raiz}/${LOCAL}`))
772 const leido = JSON.parse(textoLocal) as unknown
773 if (leido && typeof leido === 'object' && !Array.isArray(leido)) local = loDelControl(leido as Estado)
774 }
775 } catch {
776 local = {}
777 }
778 return { estado: { ...declarado, ...local }, declarado, textoLocal }
779}
780
781/**
782 * Segundo camino para LEER las issues, el mismo con que ya se mide el avance: la credencial que `gh` guarda
783 * para la cuenta dueña del repositorio. Sólo se usa si el envoltorio del repositorio no respondió, sólo lee, y
784 * sólo si esa credencial identifica de verdad a la dueña: nunca la cuenta activa de `gh` ni otra identidad.
785 */
786async function leerIssuesComoDuena($: any, raiz: string, repoGh: string, base: string[]): Promise<{ r: { ok: boolean; stdout: string; stderr: string }; conProyecto: boolean; duena: string } | null> {
787 const duena = repoGh.split('/')[0] ?? ''
788 if (!duena) return null
789 const t = await correr($, ['gh', 'auth', 'token', '--user', duena], raiz, 20_000)
790 const credencial = t.stdout.trim()
791 if (!t.ok || !credencial) return null
792 const env = { GH_TOKEN: credencial }
793 const quien = await correr($, ['gh', 'api', 'user', '--jq', '.login'], raiz, 20_000, env)
794 if (!quien.ok || quien.stdout.trim().toLowerCase() !== duena.toLowerCase()) return null
795 let r = await correr($, ['gh', ...base, 'number,title,labels,body,projectItems'], raiz, 60_000, env)
796 if (r.ok) return { r, conProyecto: true, duena }
797 r = await correr($, ['gh', ...base, 'number,title,labels,body'], raiz, 60_000, env)
798 return r.ok ? { r, conProyecto: false, duena } : null
799}
800
801/** Las issues abiertas, de GitHub y no de un recuerdo. Si GitHub no responde, se dice y no se inventa nada. */
802async function leerIssues($: any, raiz: string, repoGh: string, envoltorio: string): Promise<{ issues: Issue[] | null; github: string }> {
803 if (!repoGh) return { issues: null, github: 'NO MEDIDO: el repositorio no tiene remoto de GitHub' }
804 const base = ['issue', 'list', '--repo', repoGh, '--state', 'open', '--limit', '300', '--json']
805 let conProyecto = true
806 let r = { ok: false, stdout: '', stderr: 'el repositorio no declara herramientas.gh en .claude/orquestacion.json' }
807 if (envoltorio) {
808 r = await correr($, ghDelProyecto(raiz, envoltorio, ...base, 'number,title,labels,body,projectItems'), raiz, 60_000)
809 if (!r.ok) {
810 conProyecto = false
811 r = await correr($, ghDelProyecto(raiz, envoltorio, ...base, 'number,title,labels,body'), raiz, 60_000)
812 }
813 }
814 let via = ''
815 if (!r.ok) {
816 const motivo = primeraLinea(r.stderr) || `${envoltorio} no respondió`
817 const otra = await leerIssuesComoDuena($, raiz, repoGh, base)
818 if (!otra) return { issues: null, github: `NO MEDIDO: ${motivo}` }
819 r = otra.r
820 conProyecto = otra.conProyecto
821 via = `leído con la credencial que gh guarda para «${otra.duena}», porque el envoltorio del repositorio no respondió (${motivo}); escribir en las issues sigue esperando a ese envoltorio`
822 }
823 try {
824 const filas = JSON.parse(r.stdout) as { number: number; title: string; labels?: { name: string }[]; body?: string; projectItems?: unknown[] }[]
825 return {
826 issues: filas.map(f => ({
827 numero: f.number,
828 titulo: f.title ?? '',
829 etiquetas: (f.labels ?? []).map(l => l.name),
830 cuerpo: f.body ?? '',
831 enProyecto: conProyecto ? (f.projectItems ?? []).length > 0 : null,
832 commits: [],
833 medida: true,
834 })),
835 github: ['ok', conProyecto ? '' : 'sin la pertenencia al proyecto (la credencial no la deja leer)', via].filter(Boolean).join(', '),
836 }
837 } catch {
838 return { issues: null, github: 'NO MEDIDO: la respuesta de gh no es JSON' }
839 }
840}
841
842/** Qué commits de main nombran cada issue: un solo `git log --grep`, repartido por número. */
843async function leerCommits($: any, raiz: string, numeros: number[]): Promise<Map<number, string[]>> {
844 const mapa = new Map<number, string[]>(numeros.map(n => [n, []]))
845 if (!numeros.length) return mapa
846 const r = await correr($, ['git', '-C', raiz, 'log', 'main', '--oneline', '-E', `--grep=#(${numeros.join('|')})([^0-9]|$)`, '-n', '400'], raiz)
847 for (const linea of r.stdout.split(/\r?\n/)) {
848 for (const n of numeros) {
849 if (new RegExp(`#${n}(?!\\d)`).test(linea)) mapa.get(n)!.push(linea.trim().slice(0, 120))
850 }
851 }
852 return mapa
853}
854
855/** Actividad de cada externo, medida por su rama agente/número: lo último entre su commit y sus ficheros sin confirmar. */
856async function leerActividad($: any, raiz: string, ahoraMs: number): Promise<Actividad> {
857 const actividad: Actividad = {}
858 for (const quien of EXTERNOS) {
859 const suArbol = arbol(raiz, quien)
860 const rama = (await correr($, ['git', '-C', suArbol, 'rev-parse', '--abbrev-ref', 'HEAD'], raiz, 15_000)).stdout.trim()
861 const m = new RegExp(`^${quien}/(\\d+)`).exec(rama)
862 if (!m) {
863 actividad[quien] = null
864 continue
865 }
866 let ultimo = Number((await correr($, ['git', '-C', suArbol, 'log', '-1', '--format=%ct'], raiz, 15_000)).stdout.trim()) * 1000 || 0
867 // Sin candados opcionales: medir en el árbol de otro agente no puede competir por su index.lock.
868 const sucios = (await correr($, ['git', '--no-optional-locks', '-C', suArbol, 'status', '--porcelain'], raiz, 20_000)).stdout.split(/\r?\n/).filter(Boolean)
869 // El stash es del repositorio entero: cuentan las entradas que git anotó como salidas de esta rama.
870 const guardado = (await correr($, ['git', '--no-optional-locks', '-C', suArbol, 'stash', 'list', '--format=%gs'], raiz, 15_000)).stdout.split(/\r?\n/).filter(l => l.startsWith(`WIP on ${rama}:`) || l.startsWith(`On ${rama}:`))
871 for (const linea of sucios.slice(0, 20)) {
872 const nombre = linea.slice(3).trim().replace(/^"|"$/g, '').split(' -> ').pop()!
873 try {
874 ultimo = Math.max(ultimo, (await $.fs.stat(`${suArbol}/${nombre}`)).mtimeMs)
875 } catch {
876 // Un fichero borrado no tiene hora: no cuenta.
877 }
878 }
879 actividad[quien] = { rama, issue: Number(m[1]), hace_min: ultimo ? (ahoraMs - ultimo) / 60_000 : Number.POSITIVE_INFINITY, sin_confirmar: sucios.length, stash: guardado.length }
880 }
881 return actividad
882}
883
884async function candadoDeSuite($: any, raiz: string, estado: Estado): Promise<string> {
885 try {
886 const fichero = `${raiz}/${rutaDelRepositorio(estado.herramientas?.candado) || CANDADO_SI_EXISTE}`
887 if (!(await $.fs.exists(fichero))) return ''
888 return primeraLinea(String(await $.fs.read(fichero)).replace(/\s+/g, ' ')) || 'tomado'
889 } catch {
890 return ''
891 }
892}
893
894type Decision = { salida: Salida; github: string; orden: string; estado: Estado; raiz: string; repoGh: string; envoltorio: string }
895
896/**
897 * El control: mide, decide (router.ts) y escribe. Sólo actúa en un repositorio que tenga .claude/orquestacion.json.
898 * `cambio` aplica una orden a mano (fuera, tomar) sobre lo leído antes de decidir; si devuelve texto, es un rechazo.
899 */
900async function controlar($: any, repoCfg: string, cambio?: (estado: Estado, issues: Issue[]) => string | null): Promise<Decision | { error: string } | null> {
901 const raiz = barras(String(await $.session.root()))
902 if (!(await $.fs.exists(`${raiz}/.claude/orquestacion.json`))) {
903 await update($, control, c => ({ ...c, activo: false }))
904 return null
905 }
906 const { estado: leido, declarado, textoLocal: antes } = await leerEstado($, raiz)
907 const fueraAntes = JSON.stringify(leido.fuera ?? {})
908 const repoGh = await repoDe($, repoCfg)
909 const envoltorio = await envoltorioDeGh($, raiz, leido)
910 const { issues: medidas, github } = await leerIssues($, raiz, repoGh, envoltorio)
911 // Sin GitHub no se da nada por cerrado ni se inventan títulos: se enruta la cola escrita, marcada como no medida.
912 const colaEscrita = leido.cola?.length ? leido.cola : [...COLA_INICIAL]
913 const issues: Issue[] =
914 medidas ??
915 [...new Set([...colaEscrita, ...Object.keys(leido.asignaciones ?? {}).map(Number)])].map(numero => ({
916 numero,
917 titulo: leido.clasificacion?.find(f => f.numero === numero)?.titulo ?? '',
918 etiquetas: [],
919 cuerpo: '',
920 enProyecto: null,
921 commits: [],
922 medida: false,
923 }))
924 const commits = await leerCommits($, raiz, issues.map(i => i.numero))
925 for (const i of issues) i.commits = commits.get(i.numero) ?? []
926 if (cambio) {
927 const rechazo = cambio(leido, issues)
928 if (rechazo) return { error: rechazo }
929 }
930 const ahoraMs = await $.clock.now()
931 const salida = enrutar({
932 raiz,
933 issues,
934 estado: leido,
935 actividad: await leerActividad($, raiz, ahoraMs),
936 ahora: new Date(ahoraMs).toISOString(),
937 abiertas: medidas ? new Set(medidas.map(i => i.numero)) : null,
938 })
939 const estado = conDecision(leido, salida, github, medidas ? medidas.length : null, leido.router?.pendientes ?? [])
940 const despues = `${JSON.stringify(loDelControl(estado), null, 2)}\n`
941 // La decisión va al fichero local, y sólo si cambió. El versionado no se toca.
942 if (despues !== antes) await $.fs.write(`${raiz}/${LOCAL}`, despues)
943 // La única escritura en el versionado es una orden de la persona: «/consumo fuera» declara quién no recibe nada.
944 if (JSON.stringify(estado.fuera ?? {}) !== fueraAntes) await $.fs.write(`${raiz}/${VERSIONADO}`, `${JSON.stringify({ ...declarado, fuera: estado.fuera }, null, 2)}\n`)
945 // Las señales son los cuellos de botella de la foto vigente. Con la foto vieja, o sin index, no hay señales:
946 // el control no decide con lo que ya no se sabe si es verdad.
947 const foto = await releerIndex($, repoCfg, false)
948 const o = orden(estado, foto.lectura.vigente ? foto.lectura.senales : [])
949 const previo = await read($, control)
950 // Al prompt de sistema va la orden tal como quedó cuando cambió: reescribirla en cada foto rompería su caché.
951 const ordenPrompt = previo.firma === o.firma && previo.ordenPrompt ? previo.ordenPrompt : o.texto
952 const nuevo: Control = { cuando: ahoraMs, activo: true, github, candado: await candadoDeSuite($, raiz, estado), frases: frases(salida, orden(estado), estado.declarado), resto: resto(salida), avisos: salida.avisos, orden: o.texto, ordenPrompt, firma: o.firma, causas: o.causas.length }
953 await update($, control, () => nuevo)
954 if (foto.instalado && foto.lectura.vigente && foto.lectura.repositorio) await anotarDecision($, foto.carpeta, foto.lectura.repositorio, o, ahoraMs)
955 return { salida, github, orden: nuevo.orden, estado, raiz, repoGh, envoltorio }
956}
957
958// Lo que hizo el último despertar se guarda en el almacén del mod, por raíz de repositorio, y no en
959// .claude/orquestacion.json: ese fichero está versionado y sólo se reescribe cuando la decisión cambia.
960const claveDeDespertar = (raiz: string): string => `despertar:${barras(raiz).toLowerCase()}`
961
962async function anotarDespertar($: any, r: Decision): Promise<void> {
963 try {
964 const clave = claveDeDespertar(r.raiz)
965 const anterior = comoDespertar(await $.store.get(clave))
966 await $.store.set(clave, registroDeDespertar(r.estado, anterior, await $.clock.now()))
967 } catch {
968 // Sin almacén, el index dirá «sin despertares registrados»; la orden no depende de esto.
969 }
970}
971
972/**
973 * El index: una ficha por repositorio del reparto, sacada de su .claude/orquestacion.json y del último despertar
974 * guardado. Escribe, en la carpeta que da la medición (la del usuario, fuera de cualquier repositorio), el index
975 * de tareas —privado— y, sólo para quien declara `produccion.salud`, una ficha de producción aparte, que se arma
976 * con lo que esa URL publica y con nada del estado. Devuelve la ruta del index, o '' si no se escribió.
977 */
978async function generarIndex($: any, reparto: Avance['reparto'], carpeta: string): Promise<string> {
979 if (!carpeta) return ''
980 const destino = barras(carpeta)
981 const ahora = await $.clock.now()
982 const fichas: Ficha[] = []
983 for (const p of reparto) {
984 const raiz = p.raiz ? barras(p.raiz) : ''
985 let estado: Estado | null = null
986 let despertar = null
987 try {
988 if (raiz && (await $.fs.exists(`${raiz}/${VERSIONADO}`))) estado = (await leerEstado($, raiz)).estado
989 } catch {
990 estado = null
991 }
992 if (estado) {
993 try {
994 despertar = comoDespertar(await $.store.get(claveDeDespertar(raiz)))
995 } catch {
996 despertar = null
997 }
998 }
999 try {
1000 fichas.push(ficha(p.nombre, estado, despertar, ahora))
1001 } catch {
1002 // Un fichero con una forma inesperada no tumba el index: ese repositorio sale sin control.
1003 fichas.push(ficha(p.nombre, null, null, ahora))
1004 }
1005 const url = urlDeSalud(estado)
1006 if (url) {
1007 // A la ficha pública sólo llega lo que la URL respondió. El estado se queda de este lado.
1008 const r = await correr($, ['curl', '-s', '-m', '15', url], raiz, 20_000)
1009 const publica = fichaDeProduccion({ ...saludDe(r.ok ? r.stdout : ''), cuando: ahora })
1010 const base = `${destino}/produccion-${nombreDeArchivo(p.nombre)}`
1011 try {
1012 await $.fs.write(`${base}.html`, publica.html)
1013 await $.fs.write(`${base}.json`, publica.json)
1014 } catch {
1015 // sin disco no hay ficha; el index de tareas se intenta igual
1016 }
1017 }
1018 }
1019 const privada = paginaDeTareas(fichas, ahora)
1020 await $.fs.write(`${destino}/index.json`, privada.json)
1021 await $.fs.write(`${destino}/index.html`, privada.html)
1022 return `${destino}/index.html`
1023}
1024
1025/** La carpeta del index: la del usuario, fuera de cualquier repositorio. El módulo no la conoce; se la dice node. */
1026async function carpetaDelIndex($: any, raiz: string): Promise<string> {
1027 const r = await correr($, ['node', '-e', 'process.stdout.write(require("os").homedir())'], raiz, 15_000)
1028 return r.ok && r.stdout.trim() ? `${barras(r.stdout.trim())}/.claude/consumo-index` : ''
1029}
1030
1031/**
1032 * El index vivo. Si el guion sin modelo está instalado en la carpeta del index, se lanza y se lee de su index.json
1033 * el primer cuello de botella. No pasa por ningún modelo y no toca el prompt de sistema: va al panel y, si aparece
1034 * un cuello de gravedad alta que la foto anterior no traía, a un aviso.
1035 */
1036async function leerJson($: any, ruta: string): Promise<unknown> {
1037 try {
1038 if (!(await $.fs.exists(ruta))) return null
1039 return JSON.parse(String(await $.fs.read(ruta)))
1040 } catch {
1041 return null
1042 }
1043}
1044
1045/**
1046 * Relee index.json y diario.json, que el guion refresca solo, y deja en el estado las líneas del panel y las señales
1047 * del control. No lanza nada ni calcula nada: lee. Con `decidir`, si la decisión del control tiene más de diez
1048 * minutos la vuelve a tomar, para que el panel no enseñe una orden vieja.
1049 */
1050async function releerIndex($: any, repoCfg: string, decidir: boolean): Promise<Vivo> {
1051 const antes = await read($, vivo)
1052 try {
1053 const raiz = barras(String(await $.session.root()))
1054 const carpeta = antes.carpeta || (await carpetaDelIndex($, raiz))
1055 const instalado = Boolean(carpeta) && Boolean(await $.fs.exists(`${carpeta}/indice.mjs`))
1056 if (!instalado) {
1057 await update($, vivo, v => ({ ...v, instalado: false, carpeta, lectura: SIN_LECTURA }))
1058 return await read($, vivo)
1059 }
1060 const ahora = Number(await $.clock.now())
1061 const lectura = leerFoto(await leerJson($, `${carpeta}/index.json`), await leerJson($, `${carpeta}/diario.json`), raiz, ahora)
1062 const altas = lectura.senales.filter(c => c.gravedad === 'alta').map(c => c.que)
1063 const nuevas = altas.filter(q => !antes.altas.includes(q))
1064 const primero = lectura.cuellos[0]
1065 await update($, vivo, () => ({ instalado: true, carpeta, cuello: primero ? `${primero.gravedad} · ${primeraLinea(primero.texto)}` : '', cuellos: lectura.senales.length, altas, cuando: ahora, lectura }))
1066 // La primera foto de la sesión no avisa: todo lo que trae sería «nuevo».
1067 if (antes.cuando !== null && nuevas.length) void $.ui.toast(`Index · cuello de gravedad alta: ${primeraLinea(nuevas[0]!)}`)
1068 if (decidir) {
1069 const c = await read($, control)
1070 if (c.activo && c.cuando !== null && ahora - c.cuando > 600_000) await controlar($, repoCfg).catch(() => null)
1071 }
1072 } catch {
1073 // Un index que no se pudo leer no detiene nada: el panel conserva lo último que supo.
1074 }
1075 return await read($, vivo)
1076}
1077
1078/**
1079 * El index vivo. Con una sesión abierta, el mod lanza el mismo guion que los hooks de git y la tarea programada, y
1080 * después lo lee. No pasa por ningún modelo y no toca el prompt de sistema.
1081 */
1082async function refrescarIndexVivo($: any, repoCfg = ''): Promise<Vivo> {
1083 try {
1084 const raiz = barras(String(await $.session.root()))
1085 const antes = await read($, vivo)
1086 const carpeta = antes.carpeta || (await carpetaDelIndex($, raiz))
1087 if (carpeta && (await $.fs.exists(`${carpeta}/indice.mjs`))) await correr($, ['node', `${carpeta}/indice.mjs`], raiz, 60_000)
1088 } catch {
1089 // Si el guion no corre, se lee la foto que haya.
1090 }
1091 return await releerIndex($, repoCfg, false)
1092}
1093
1094/**
1095 * El registro de decisiones: una línea cada vez que cambian las señales que mandan en la orden (también cuando
1096 * dejan de mandar). El cálculo diario lo lee y dice cuánto tardó cada señal en desaparecer y cuántas volvieron:
1097 * es la medida de si escuchar sirve.
1098 */
1099async function anotarDecision($: any, carpeta: string, repositorio: string, o: Orden, ahoraMs: number): Promise<void> {
1100 try {
1101 const ruta = `${carpeta}/decisiones.tsv`
1102 const texto = (await $.fs.exists(ruta)) ? String(await $.fs.read(ruta)) : ''
1103 const senal = o.causas.length ? o.firma : '-'
1104 const suyas = texto.split(/\r?\n/).filter(l => l && !l.startsWith('#') && l.split('\t')[1] === repositorio)
1105 const ultima = suyas.length ? suyas[suyas.length - 1]!.split('\t')[2] : null
1106 if (ultima === senal || (ultima === null && senal === '-')) return
1107 const limpio = (t: string): string => t.replace(/[\t\r\n]+/g, ' ')
1108 const linea = [new Date(ahoraMs).toISOString(), limpio(repositorio), senal, limpio(o.causas.length ? o.causas.map(c => c.orden).join(' / ') : 'la orden de siempre')].join('\t')
1109 await $.fs.write(ruta, `${texto || '# fecha\trepositorio\tsenales\torden\n'}${texto && !texto.endsWith('\n') ? '\n' : ''}${linea}\n`)
1110 } catch {
1111 // Sin registro no hay indicador de si escuchar sirve, pero la orden vale igual.
1112 }
1113}
1114
1115function asignacionAMano(i: Issue, estado: Estado, quien: Trabajador, raiz: string, ahora: string, motivo: string): Asignacion {
1116 const d = estado.declarado?.[String(i.numero)]
1117 const { puntos } = pesar(i, d)
1118 const t = tabla(puntos)
1119 return {
1120 trabajador: quien,
1121 clase: clasificar(i, d),
1122 puntos,
1123 peso: t.peso,
1124 esfuerzo: t.esfuerzo,
1125 estado: 'vigente',
1126 desde: ahora,
1127 motivo,
1128 ...(quien === 'claude' ? {} : { arbol: arbol(raiz, quien), rama: `${quien}/${i.numero}` }),
1129 }
1130}
1131
1132/** «/consumo agentes | fuera <agy|codex> <motivo> | tomar <número> <agy|codex|claude>»: ejecuta el control y responde en frases. */
1133async function ordenDeControl($: any, repoCfg: string, argumentos: string): Promise<string> {
1134 const [verbo = '', a = '', ...mas] = argumentos.split(/\s+/)
1135 const raiz = barras(String(await $.session.root()))
1136 const ahora = new Date(await $.clock.now()).toISOString()
1137 let linea = ''
1138 let nota: { numero: number; texto: string } | null = null
1139 let cambio: ((estado: Estado, issues: Issue[]) => string | null) | undefined
1140
1141 if (verbo.toLowerCase() === 'fuera') {
1142 const quien = a.toLowerCase() as Trabajador
1143 const motivo = mas.join(' ').trim()
1144 if (!EXTERNOS.includes(quien) || !motivo) return 'Uso: «/consumo fuera <agy|codex> <motivo>». El motivo es obligatorio: queda visible.'
1145 cambio = estado => {
1146 estado.fuera = { ...(estado.fuera ?? {}), [quien]: `${motivo} (desde el ${ahora.slice(0, 10)})` }
1147 linea = `${quien} queda fuera: ${motivo}.`
1148 return null
1149 }
1150 } else if (verbo.toLowerCase() === 'tomar') {
1151 const numero = Number(a.replace('#', ''))
1152 const quien = (mas[0] ?? '').toLowerCase() as Trabajador
1153 if (!Number.isInteger(numero) || numero <= 0 || !TRABAJADORES.includes(quien)) return 'Uso: «/consumo tomar <número> <agy|codex|claude>».'
1154 cambio = (estado, issues) => {
1155 const i = issues.find(x => x.numero === numero)
1156 if (!i) return `La #${numero} no está abierta ni en la cola: no se asigna.`
1157 const d = estado.declarado?.[String(numero)]
1158 const clase = clasificar(i, d)
1159 if (clase === 'Decisión') return `La #${numero} es una Decisión: espera al dueño y no se asigna a nadie.`
1160 if (clase === 'Épica') return `La #${numero} es una épica: no lleva trabajador.`
1161 const motivoFuera = estado.fuera?.[quien]
1162 if (motivoFuera) return `${quien} está fuera (${motivoFuera}): no recibe la #${numero}.`
1163 if (quien !== 'claude' && (d?.solo_sesion || pesar(i, d).criterios.produccion)) return `La #${numero} toca producción o está reservada a esta sesión: no se delega a ${quien}.`
1164 const otra = Object.entries(estado.asignaciones ?? {}).find(([n, x]) => Number(n) !== numero && (x.trabajador === quien || x.contra === quien))
1165 if (otra) return `${quien} ya tiene la #${otra[0]}. Un trabajador, una issue: libérala antes.`
1166 const antes = estado.asignaciones?.[String(numero)]?.trabajador
1167 const nueva = asignacionAMano(i, estado, quien, raiz, ahora, `reasignada a mano${antes ? ` desde ${antes}` : ''}`)
1168 estado.asignaciones = { ...(estado.asignaciones ?? {}), [String(numero)]: nueva }
1169 estado.reasignado = { ...(estado.reasignado ?? {}), [String(numero)]: `${quien} (a mano, ${ahora.slice(0, 10)})` }
1170 linea = `La #${numero} pasa a ${quien}.`
1171 nota = { numero, texto: `Control: la #${numero} pasa a ${quien}${antes ? ` (antes, ${antes})` : ''}; peso ${nueva.peso}, esfuerzo ${nueva.esfuerzo}. Reasignada a mano el ${ahora.slice(0, 10)}.` }
1172 return null
1173 }
1174 }
1175
1176 const r = await controlar($, repoCfg, cambio)
1177 if (!r) return 'Este repositorio no tiene .claude/orquestacion.json: el control no decide aquí.'
1178 if ('error' in r) return r.error
1179
1180 const anotado: string[] = []
1181 const pendiente = nota as { numero: number; texto: string } | null
1182 if (pendiente) {
1183 // Una línea en la issue, no un informe. Si GitHub no la acepta, queda pendiente y a la vista.
1184 const c = r.envoltorio
1185 ? await correr($, ghDelProyecto(r.raiz, r.envoltorio, 'issue', 'comment', String(pendiente.numero), '--repo', r.repoGh, '--body', pendiente.texto), r.raiz, 45_000)
1186 : { ok: false, stdout: '', stderr: 'el repositorio no declara herramientas.gh' }
1187 if (c.ok) anotado.push(`Anotado en la #${pendiente.numero}.`)
1188 else {
1189 anotado.push(`No se pudo anotar en la #${pendiente.numero} (${primeraLinea(c.stderr) || 'el envoltorio de gh falló'}): queda pendiente en .claude/orquestacion.local.json.`)
1190 const conPendiente = { ...r.estado, router: { ...r.estado.router!, pendientes: [...(r.estado.router?.pendientes ?? []), `#${pendiente.numero}: ${pendiente.texto}`] } }
1191 await $.fs.write(`${r.raiz}/${LOCAL}`, `${JSON.stringify(loDelControl(conPendiente), null, 2)}\n`)
1192 }
1193 }
1194 return [linea, ...frases(r.salida, orden(r.estado), r.estado.declarado), ...resto(r.salida), ...r.salida.avisos, ...anotado, r.github === 'ok' ? '' : `GitHub: ${r.github}.`].filter(Boolean).join('\n')
1195}
1196
1197async function refrescar($: any): Promise<void> {
1198 const ahora = await $.clock.now()
1199 let error = ''
1200 try {hooks/router.ts 619 lines1// El control de `consumo` 2.0, sin entrada ni salida: recibe lo medido (issues, commits, ramas, lo declarado en
2// .claude/orquestacion.json) y devuelve la decisión. Todo lo que toca disco, git o GitHub vive en
3// register.tsx; aquí sólo se decide, para que la decisión se pueda probar con datos fijos.
4
5export type Clase = 'Cierre' | 'Construcción' | 'Interina' | 'Decisión' | 'Épica'
6export type Peso = 'XS' | 'S' | 'M' | 'L' | 'XL'
7export type Trabajador = 'claude' | 'agy' | 'codex'
8export const EXTERNOS: readonly Trabajador[] = ['agy', 'codex']
9export const TRABAJADORES: readonly Trabajador[] = ['claude', 'agy', 'codex']
10
11/** Las cinco preguntas del peso. Cada «sí» es un punto; se contesta sobre LO QUE FALTA, no sobre la issue entera. */
12export type Criterios = {
13 capas: boolean
14 sensible: boolean
15 diagnostico: boolean
16 navegador: boolean
17 produccion: boolean
18}
19export const PREGUNTAS: readonly (keyof Criterios)[] = ['capas', 'sensible', 'diagnostico', 'navegador', 'produccion']
20
21export type Paso = { paso: string; hecho: boolean }
22
23/** Lo que la sesión o el dueño dejan escrito de una issue y que ni git ni GitHub saben. */
24export type Declarado = Partial<Criterios> & {
25 clase?: Clase
26 /** Qué falta, en una línea: es lo que se pesa. */
27 falta?: string
28 /** Sólo la construye esta sesión (orden de despliegue dada, trabajo sin empujar a propósito). */
29 solo_sesion?: boolean
30 /** Los pasos que la cierran, en orden. Alguien marca `hecho`; el control no lo da por hecho. */
31 pasos?: Paso[]
32 /** El criterio con que se da por bueno el paso en curso. */
33 criterio?: string
34 /** Los archivos que el trabajo toca. */
35 archivos?: string[]
36 /**
37 * Lo que sólo el dueño puede destrabar (una credencial, una decisión). Mientras esté escrito, la issue sigue
38 * siendo de quien la tiene pero no lo ocupa: el control le da la siguiente de la cola.
39 */
40 espera?: string
41}
42
43export type Issue = {
44 numero: number
45 titulo: string
46 etiquetas: string[]
47 cuerpo: string
48 /** null: no se pudo medir (GitHub no respondió). */
49 enProyecto: boolean | null
50 /** Commits de main que la nombran, «sha asunto». */
51 commits: string[]
52 /** false: la issue viene de la cola escrita y no de GitHub; título y etiquetas no están medidos. */
53 medida: boolean
54}
55
56export type Asignacion = {
57 trabajador: Trabajador
58 clase: Clase
59 puntos: number
60 peso: Peso
61 esfuerzo: string
62 /** Quien hace el CONTRA en sólo lectura (peso L), o por qué no hay nadie. */
63 contra?: Trabajador
64 sin_contra?: string
65 estado: 'vigente' | 'por retomar'
66 desde: string
67 motivo: string
68 arbol?: string
69 rama?: string
70 encargo?: string
71}
72
73export type Fila = {
74 numero: number
75 titulo: string
76 clase: Clase
77 puntos: number | null
78 peso: Peso | null
79 esfuerzo: string | null
80 trabajador: Trabajador | null
81 /** asignada · en cola · espera al dueño · épica · interina · partir · contra */
82 situacion: 'asignada' | 'en cola' | 'espera al dueño' | 'épica' | 'interina' | 'partir'
83 nota: string
84 /** De cada pregunta del peso, si la respuesta está declarada o la dedujo el router del texto. */
85 origen: Record<keyof Criterios, 'declarado' | 'deducido'> | null
86 criterios: Criterios | null
87}
88
89export type Estado = {
90 _nota?: string
91 fuera?: Record<string, string>
92 reasignado?: Record<string, string>
93 cola?: number[]
94 declarado?: Record<string, Declarado>
95 no_tocar?: string[]
96 asignaciones?: Record<string, Asignacion>
97 clasificacion?: Fila[]
98 router?: { github: string; issues_abiertas: number | null; pendientes?: string[] }
99 /** Dónde publica el servicio su salud (commit y estado). Sólo de ahí sale la ficha pública de producción. */
100 produccion?: { salud?: string }
101 /**
102 * Lo propio de cada repositorio, con rutas relativas a su raíz: `gh`, el envoltorio con que se habla con GitHub;
103 * `candado`, el fichero del candado de suites; `migraciones`, la carpeta de migraciones; `incidentes`, el
104 * registro de incidentes de coordinación.
105 */
106 herramientas?: { gh?: string; candado?: string; migraciones?: string; incidentes?: string }
107 /** La tabla de reservas de números de migración. Las claves que empiezan por «_» y `siguiente_libre` no son reservas. */
108 migraciones?: Record<string, unknown>
109 [otra: string]: unknown
110}
111
112/**
113 * Lo medido en el árbol de cada trabajador externo; null si no tiene rama a la vista. `hace_min`: minutos desde
114 * su última actividad. `sin_confirmar` y `stash`: ficheros sin confirmar y entradas de stash de esa rama; con
115 * cualquiera de los dos, el trabajador tiene trabajo a medias aunque no haya commits recientes.
116 */
117export type Actividad = Partial<Record<Trabajador, { rama: string; issue: number; hace_min: number; sin_confirmar?: number; stash?: number } | null>>
118
119export const MOTIVO_A_MEDIAS = 'tiene trabajo a medias: preguntar al dueño antes de reasignar'
120
121export const INACTIVO_MIN = 30
122
123// La cola la escribe cada repositorio en su .claude/orquestacion.json (`cola`). Sin ella, el control sigue el
124// orden de las issues que no figuran en ninguna: por número.
125export const COLA_INICIAL: readonly number[] = []
126
127// Lo que un externo no toca si el repositorio no escribe su propia lista en `no_tocar`: sólo lo que vale para
128// cualquier repositorio. Lo propio de cada producto va en su .claude/orquestacion.json.
129export const NO_TOCAR_INICIAL: readonly string[] = ['.env', 'las migraciones ya integradas (sólo migraciones nuevas, y sólo si el encargo las pide)', 'CLAUDE.md', '.claude/orquestacion.json']
130
131export const FRASE_ENCARGO = 'No abras issues ni dejes un comentario como cierre.'
132
133/** Cada externo trabaja en su propio árbol, junto al del proyecto: <raíz>-agy y <raíz>-codex, rama <agente>/<número>. */
134export function arbol(raiz: string, quien: Trabajador): string {
135 return quien === 'claude' ? raiz : `${raiz}-${quien}`
136}
137
138const TABLA: readonly { peso: Peso; esfuerzo: string }[] = [
139 { peso: 'XS', esfuerzo: 'bajo' },
140 { peso: 'S', esfuerzo: 'medio' },
141 { peso: 'M', esfuerzo: 'alto' },
142 { peso: 'L', esfuerzo: 'muy alto' },
143 { peso: 'XL', esfuerzo: 'máximo, con plan antes' },
144 { peso: 'XL', esfuerzo: 'máximo, con plan antes' },
145]
146
147/** Puntos → peso y esfuerzo. El modelo no se asigna: es el de la sesión, y se lee. */
148export function tabla(puntos: number): { peso: Peso; esfuerzo: string } {
149 return TABLA[Math.max(0, Math.min(5, puntos))]!
150}
151
152const sinAcentos = (t: string): string => t.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase()
153
154function tieneEtiqueta(i: Issue, patron: RegExp): boolean {
155 return i.etiquetas.some(e => patron.test(sinAcentos(e)))
156}
157
158/** Una sola clase por issue. Lo declarado manda; después, en este orden: Épica, Decisión, Interina, Cierre, Construcción. */
159export function clasificar(i: Issue, d: Declarado | undefined): Clase {
160 if (d?.clase) return d.clase
161 const titulo = sinAcentos(i.titulo)
162 if (tieneEtiqueta(i, /^(type:)?(epic|epica)$/) || /^\s*(\[epic\]|epica\b)/.test(titulo)) return 'Épica'
163 if (tieneEtiqueta(i, /^decision-humana$|^type:decision$|^credencial/) || /\bp-\d{1,2}\b/.test(titulo)) return 'Decisión'
164 if (tieneEtiqueta(i, /^interina$/)) return 'Interina'
165 if (i.commits.length > 0) return 'Cierre'
166 return 'Construcción'
167}
168
169const CAPAS: readonly RegExp[] = [
170 /migraci|\bddl\b|\bsql\b|base de datos|\bdb\//,
171 /\bapi\b|controlador|controller|endpoint|guard\b|servicio de dominio/,
172 /contrato|openapi|packages\/contracts/,
173 /\bweb\b|pantalla|pagina|page\.tsx|frontend|formulario/,
174 /packages\/ui|componente de ui/,
175]
176
177/** Deduce del texto las cinco respuestas. Es una deducción y así queda marcada: lo declarado la pisa pregunta a pregunta. */
178export function deducir(i: Issue): Criterios {
179 const texto = sinAcentos(`${i.titulo}\n${i.etiquetas.join(' ')}\n${i.cuerpo}`)
180 const capas = CAPAS.filter(c => c.test(texto)).length
181 return {
182 capas: capas > 1,
183 sensible: /migraci|\brls\b|permiso|\bsesion\b|membresi|datos personales|\bpii\b|\brol(es)?\b/.test(texto),
184 diagnostico: /causa (no|sin) (medida|conocida|demostrada)|no medido|intermitente|sin diagnostic|se desconoce/.test(texto),
185 navegador: /navegador|playwright|pnpm e2e|pnpm gate|\b1440\b|\b390\b|recorrido por rol/.test(texto),
186 produccion: /produccion|desplegar|despliegue|vigia|pnpm vigilar|preflight|\bpuerta\b|pnpm verify/.test(texto),
187 }
188}
189
190const PESOS: readonly Peso[] = ['XS', 'S', 'M', 'L', 'XL']
191
192/** El peso que la issue trae escrito en una etiqueta `peso:XS|S|M|L|XL`, o null si no la trae. */
193export function pesoDeEtiqueta(i: Issue): Peso | null {
194 for (const e of i.etiquetas) {
195 const m = /^peso:\s*(xs|s|m|l|xl)$/i.exec(e.trim())
196 if (m) return m[1]!.toUpperCase() as Peso
197 }
198 return null
199}
200
201/** La etiqueta que dice que la issue no puede avanzar sola. */
202export function estaBloqueada(i: Issue): boolean {
203 return tieneEtiqueta(i, /^(bloquead[ao]|blocked)$/)
204}
205
206/**
207 * El peso de lo que falta. Primero la etiqueta `peso:…` de la issue: quien la puso leyó la issue entera. Sólo si
208 * no la hay se deduce del texto, que es una deducción por palabras y se pasa. Lo declarado manda sobre las dos
209 * cuando contesta las cinco preguntas; si contesta sólo alguna, corrige esa respuesta pero no pisa la etiqueta.
210 */
211export function pesar(i: Issue, d: Declarado | undefined): { criterios: Criterios; origen: Record<keyof Criterios, 'declarado' | 'deducido'>; puntos: number; etiqueta: Peso | null } {
212 const deducido = deducir(i)
213 const criterios = {} as Criterios
214 const origen = {} as Record<keyof Criterios, 'declarado' | 'deducido'>
215 for (const p of PREGUNTAS) {
216 const dicho = d?.[p]
217 criterios[p] = typeof dicho === 'boolean' ? dicho : deducido[p]
218 origen[p] = typeof dicho === 'boolean' ? 'declarado' : 'deducido'
219 }
220 const etiqueta = pesoDeEtiqueta(i)
221 const todoDeclarado = PREGUNTAS.every(p => origen[p] === 'declarado')
222 const puntos = etiqueta !== null && !todoDeclarado ? PESOS.indexOf(etiqueta) : PREGUNTAS.filter(p => criterios[p]).length
223 return { criterios, origen, puntos, etiqueta: todoDeclarado ? null : etiqueta }
224}
225
226/** El criterio de aceptación tal como lo trae la issue; si no tiene sección reconocible, se dice y se remite a ella. */
227export function criterioDeAceptacion(i: Issue): string {
228 const lineas = i.cuerpo.split(/\r?\n/)
229 const desde = lineas.findIndex(l => /^#{1,4}\s*(criterios?( de aceptaci[oó]n)?|definici[oó]n de terminado|dod)\b/i.test(l.trim()))
230 if (desde < 0) return `Los criterios escritos en la issue #${i.numero} (no trae una sección «Criterios de aceptación» que se pueda copiar aquí).`
231 const resto = lineas.slice(desde + 1)
232 const hasta = resto.findIndex(l => /^#{1,4}\s/.test(l.trim()))
233 return (hasta < 0 ? resto : resto.slice(0, hasta)).join('\n').trim().slice(0, 2400)
234}
235
236export function encargo(i: Issue, quien: Trabajador, papel: 'construir' | 'contra', noTocar: readonly string[], raiz: string): string {
237 const cabecera =
238 papel === 'construir'
239 ? `Encargo para ${quien}: issue #${i.numero}${i.titulo ? ` — ${i.titulo}` : ''}. Trabajas en ${arbol(raiz, quien)}, rama ${quien}/${i.numero}. Una sola issue.`
240 : `Encargo para ${quien}: CONTRA en sólo lectura de la issue #${i.numero}${i.titulo ? ` — ${i.titulo}` : ''}. Lees el árbol y dictaminas; no editas, no ejecutas suites, no cierras.`
241 return [
242 cabecera,
243 `Criterio de aceptación: ${criterioDeAceptacion(i)}`,
244 `No puedes tocar: ${noTocar.join('; ')}.`,
245 'No empujas main, no migras producción y no despliegas el API: eso lo hace la sesión principal con orden del dueño.',
246 FRASE_ENCARGO,
247 ].join('\n')
248}
249
250export type Entrada = {
251 /** Raíz del repositorio de trabajo, con barras normales. */
252 raiz: string
253 issues: Issue[]
254 estado: Estado
255 actividad: Actividad
256 /** ISO de ahora; va a `desde` de las asignaciones nuevas. */
257 ahora: string
258 /** Números que GitHub dice abiertos; null si no se pudo medir (entonces no se da nada por cerrado). */
259 abiertas: ReadonlySet<number> | null
260}
261
262export type Salida = { filas: Fila[]; asignaciones: Record<string, Asignacion>; avisos: string[] }
263
264/**
265 * Decide. Reglas, en orden:
266 * 1. Una asignación vigente no se reasigna, salvo que su trabajador esté `fuera`, su issue se haya cerrado, o
267 * estuviera «por retomar» desde el router anterior.
268 * 2. Un externo sin actividad en su rama más de 30 minutos pasa a «por retomar»: este router lo marca y el
269 * siguiente lo reasigna. No se queda marcado.
270 * 3. Un trabajador, una issue (construir o hacer el CONTRA lo ocupa por igual).
271 * 4. Decisión: nadie; «espera al dueño». Épica e Interina: sin modelo ni trabajador. XL: no se asigna entera.
272 * 5. Lo que toca producción o está marcado `solo_sesion` sólo lo construye esta sesión.
273 * 6. Una asignación cuya issue tiene `espera` escrito no ocupa a nadie: sigue siendo de su trabajador y sigue
274 * a la vista, pero ni él ni su revisor quedan retenidos y la cola no se detiene detrás de ella. Tampoco pasa a
275 * «por retomar» por falta de actividad: no hay nada que mover mientras el dueño no la destrabe.
276 */
277export function enrutar(e: Entrada): Salida {
278 const fuera = Object.fromEntries(Object.entries(e.estado.fuera ?? {}).map(([k, v]) => [k.toLowerCase(), String(v)]))
279 const declarado = e.estado.declarado ?? {}
280 const noTocar = e.estado.no_tocar?.length ? e.estado.no_tocar : NO_TOCAR_INICIAL
281 const cola = e.estado.cola?.length ? e.estado.cola : COLA_INICIAL
282 const porNumero = new Map(e.issues.map(i => [i.numero, i]))
283 const avisos: string[] = []
284 const asignaciones: Record<string, Asignacion> = {}
285 const ocupado = new Map<Trabajador, number>()
286
287 // 1 y 2 · lo que ya estaba asignado.
288 for (const [clave, previa] of Object.entries(e.estado.asignaciones ?? {})) {
289 const numero = Number(clave)
290 const quien = previa.trabajador
291 if (e.abiertas && !e.abiertas.has(numero)) {
292 avisos.push(`#${numero} ya no está abierta: ${quien} queda libre.`)
293 continue
294 }
295 if (fuera[quien]) {
296 avisos.push(`#${numero} sale de ${quien} (fuera: ${fuera[quien]}).`)
297 continue
298 }
299 if (previa.estado === 'por retomar') {
300 avisos.push(`#${numero} estaba por retomar en ${quien}: se reasigna.`)
301 continue
302 }
303 const act = quien === 'claude' ? undefined : e.actividad[quien]
304 // Recién asignada todavía no hay rama: los 30 minutos corren desde lo último entre el encargo y la rama.
305 const desdeEncargo = (Date.parse(e.ahora) - Date.parse(previa.desde)) / 60_000
306 const enRama = act && act.issue === numero ? act.hace_min : Number.POSITIVE_INFINITY
307 const enEspera = Boolean(declarado[clave]?.espera)
308 const sinMovimiento = !enEspera && quien !== 'claude' && !(Math.min(enRama, Number.isFinite(desdeEncargo) ? desdeEncargo : Number.POSITIVE_INFINITY) <= INACTIVO_MIN)
309 // «A medias» no es «parado»: con ficheros sin confirmar o con entradas en el stash de su rama, el trabajo
310 // existe aunque no haya commits. No se reasigna por reloj: se le pregunta al dueño.
311 const sinConfirmar = act && act.issue === numero ? act.sin_confirmar ?? 0 : 0
312 const enStash = act && act.issue === numero ? act.stash ?? 0 : 0
313 const aMedias = sinMovimiento && (sinConfirmar > 0 || enStash > 0)
314 const parado = sinMovimiento && !aMedias
315 const contra = previa.contra && !fuera[previa.contra] ? previa.contra : undefined
316 asignaciones[clave] = parado
317 ? { ...previa, contra, estado: 'por retomar', motivo: act && act.issue === numero ? `sin actividad en ${act.rama} hace ${Math.round(act.hace_min)} min` : `sin rama ${quien}/${numero} a la vista` }
318 : aMedias
319 ? { ...previa, contra, estado: 'vigente', motivo: MOTIVO_A_MEDIAS }
320 : { ...previa, contra }
321 if (aMedias) avisos.push(`#${numero}: ${quien} no tiene commits en ${act!.rama} hace más de ${INACTIVO_MIN} min, pero ${[sinConfirmar ? `${sinConfirmar} ficheros sin confirmar` : '', enStash ? `${enStash} entradas en el stash` : ''].filter(Boolean).join(' y ')}: ${MOTIVO_A_MEDIAS}.`)
322 if (!enEspera) {
323 ocupado.set(quien, numero)
324 if (contra) ocupado.set(contra, numero)
325 }
326 if (parado) avisos.push(`#${numero} pasa a «por retomar» (${asignaciones[clave]!.motivo}); el siguiente router la reasigna.`)
327 }
328
329 const libre = (t: Trabajador): boolean => !fuera[t] && !ocupado.has(t)
330 const sesionEnDespliegue = (): boolean => {
331 const n = ocupado.get('claude')
332 if (n === undefined) return false
333 const i = porNumero.get(n)
334 return i ? pesar(i, declarado[String(n)]).criterios.produccion : false
335 }
336
337 // 3 a 5 · la cola, en su orden; después, lo que esté abierto y no figure en ella.
338 const orden = [...cola.filter(n => porNumero.has(n)), ...e.issues.map(i => i.numero).filter(n => !cola.includes(n)).sort((a, b) => a - b)]
339 const filas: Fila[] = []
340 let colaDetenida = false
341 for (const numero of orden) {
342 const i = porNumero.get(numero)!
343 const d = declarado[String(numero)]
344 const clase = clasificar(i, d)
345 const base = { numero, titulo: i.titulo, clase }
346 const vacia = { puntos: null, peso: null, esfuerzo: null, trabajador: null, origen: null, criterios: null }
347 if (clase === 'Decisión') {
348 filas.push({ ...base, ...vacia, situacion: 'espera al dueño', nota: 'espera al dueño' })
349 continue
350 }
351 if (clase === 'Épica') {
352 filas.push({ ...base, ...vacia, situacion: 'épica', nota: 'se cierra sola cuando cierran sus hijas' })
353 continue
354 }
355 // La etiqueta «bloqueada» deja la issue esperando al dueño, salvo que lo declarado diga otra cosa: si el
356 // repositorio escribió algo sobre ella (clase, pasos, lo que falta), eso manda. Una que ya tiene dueño no se suelta.
357 if (estaBloqueada(i) && d === undefined && !asignaciones[String(numero)]) {
358 filas.push({ ...base, ...vacia, situacion: 'espera al dueño', nota: 'espera al dueño: lleva la etiqueta «bloqueada» y nada declarado la destraba' })
359 continue
360 }
361 if (clase === 'Interina') {
362 filas.push({ ...base, ...vacia, situacion: 'interina', nota: 'código en main bajo decisión interina: no se cierra sin la ratificación del dueño' })
363 continue
364 }
365 const ya0 = asignaciones[String(numero)]
366 const sinMedir = !i.medida && !PREGUNTAS.some(p => typeof d?.[p] === 'boolean')
367 if (sinMedir && !ya0) {
368 // Sin GitHub y sin nada declarado no hay con qué pesar: no se asigna a ciegas ni se salta la cola.
369 colaDetenida = true
370 filas.push({ ...base, ...vacia, situacion: 'en cola', nota: 'peso sin medir: GitHub no respondió y no hay nada declarado sobre lo que falta' })
371 continue
372 }
373 const { criterios, origen, puntos, etiqueta } = pesar(i, d)
374 const t = tabla(puntos)
375 const medido = { puntos, peso: t.peso, esfuerzo: t.esfuerzo, origen, criterios }
376 const falta = d?.falta ? ` Falta: ${d.falta}` : ''
377
378 const ya = asignaciones[String(numero)]
379 if (ya) {
380 // Vigente: no se reasigna. El peso se vuelve a medir y se anota, porque lo que falta cambia.
381 asignaciones[String(numero)] = { ...ya, clase, puntos, peso: t.peso, esfuerzo: t.esfuerzo }
382 filas.push({ ...base, ...medido, trabajador: ya.trabajador, situacion: 'asignada', nota: `${ya.estado}: ${ya.motivo}.${falta}` })
383 continue
384 }
385 if (t.peso === 'XL') {
386 filas.push({ ...base, ...medido, trabajador: null, situacion: 'partir', nota: `no se asigna entera: se parte en issues de peso L o M, con plan antes.${falta}` })
387 continue
388 }
389
390 const soloSesion = Boolean(d?.solo_sesion) || criterios.produccion
391 let quien: Trabajador | null = null
392 let motivo = ''
393 if (libre('claude') && !colaDetenida) {
394 quien = 'claude'
395 motivo = `primera de la cola sin dueño; peso ${t.peso}`
396 } else if (t.peso === 'M' && !soloSesion && sesionEnDespliegue()) {
397 if (!i.medida) {
398 // Un encargo cerrado lleva el criterio de aceptación de la issue: sin leerla no hay qué encargar.
399 motivo = 'iría a agy, pero GitHub no respondió y sin leer la issue no hay criterio de aceptación que encargarle'
400 } else if (libre('agy')) {
401 quien = 'agy'
402 motivo = `peso M y la sesión está en el despliegue de #${ocupado.get('claude')}`
403 } else {
404 motivo = fuera.agy ? `iría a agy, que está fuera (${fuera.agy})` : `iría a agy, ocupado con #${ocupado.get('agy')}`
405 }
406 }
407 if (!quien) {
408 const tras = ocupado.get('claude')
409 filas.push({ ...base, ...medido, trabajador: null, situacion: 'en cola', nota: `en cola${tras === undefined ? '' : ` tras #${tras}`}${motivo ? `; ${motivo}` : ''}${soloSesion ? '; sólo la construye esta sesión' : ''}.${falta}` })
410 continue
411 }
412
413 // Regla 6: la que espera al dueño se anota a su trabajador, pero no lo ocupa ni retiene a un revisor.
414 const enEspera = Boolean(d?.espera)
415 if (enEspera) motivo = `${motivo}; espera al dueño y no ocupa a ${quien}`
416 const nueva: Asignacion = { trabajador: quien, clase, puntos, peso: t.peso, esfuerzo: t.esfuerzo, estado: 'vigente', desde: e.ahora, motivo }
417 if (!enEspera) ocupado.set(quien, numero)
418 if (quien !== 'claude') {
419 nueva.arbol = arbol(e.raiz, quien)
420 nueva.rama = `${quien}/${numero}`
421 nueva.encargo = encargo(i, quien, 'construir', noTocar, e.raiz)
422 }
423 // Lo reservado a esta sesión no se reabre: no se le busca revisor.
424 if (t.peso === 'L' && !d?.solo_sesion && !enEspera) {
425 const lector = EXTERNOS.find(x => x !== quien && libre(x))
426 if (lector) {
427 nueva.contra = lector
428 ocupado.set(lector, numero)
429 nueva.encargo = encargo(i, lector, 'contra', noTocar, e.raiz)
430 } else {
431 nueva.sin_contra = EXTERNOS.map(x => (fuera[x] ? `${x} fuera (${fuera[x]})` : `${x} ocupado con #${ocupado.get(x)}`)).join('; ')
432 avisos.push(`#${numero} (peso L) sin CONTRA externo: ${nueva.sin_contra}.`)
433 }
434 }
435 asignaciones[String(numero)] = nueva
436 filas.push({ ...base, ...medido, trabajador: quien, situacion: 'asignada', nota: `${motivo}.${falta}` })
437 }
438 return { filas, asignaciones, avisos }
439}
440
441export const NOMBRE: Record<Trabajador, string> = { claude: 'Esta sesión', agy: 'Agy', codex: 'Codex' }
442
443/** Qué hará el próximo despertar con una asignación: seguir, reasignar o esperar. Es la orden, no un estado. */
444export function proximo(a: Asignacion): string {
445 if (a.estado === 'por retomar') return 'El próximo despertar se la da al siguiente que esté libre'
446 if (a.trabajador === 'claude') return 'El próximo despertar sigue con ella y no toma otra'
447 return `El próximo despertar la deja donde está si la rama ${a.rama ?? `${a.trabajador}/…`} se movió en los últimos ${INACTIVO_MIN} minutos; si no, la marca por retomar`
448}
449
450/** Una frase por issue asignada: quién, qué número, qué peso y qué hará el próximo despertar. */
451export function frases(s: Salida, parada?: { parar: boolean; numero: number | null }, declarado?: Record<string, Declarado>): string[] {
452 const salida: string[] = []
453 for (const [clave, a] of Object.entries(s.asignaciones)) {
454 const espera = declarado?.[clave]?.espera
455 if (espera) {
456 salida.push(`${NOMBRE[a.trabajador]} tiene la #${clave} (${a.clase}), peso ${a.peso}, pero espera al dueño: ${espera}. No lo ocupa: el control sigue con la siguiente de la cola.`)
457 continue
458 }
459 const contra = a.contra ? `; ${NOMBRE[a.contra]} la revisa en sólo lectura` : a.sin_contra ? `; nadie libre para revisarla (${a.sin_contra})` : ''
460 const retomar = a.estado === 'por retomar' ? ` Por retomar: ${a.motivo}.` : ''
461 salida.push(`${NOMBRE[a.trabajador]} tiene la #${clave} (${a.clase}), peso ${a.peso}, esfuerzo ${a.esfuerzo}${contra}.${retomar} ${parada?.parar && parada.numero === Number(clave) ? 'El próximo despertar para: no le queda un paso que se pueda dar' : proximo(a)}.`)
462 }
463 if (salida.length === 0) salida.push('Nadie tiene una issue: no queda nada que se pueda asignar sin el dueño. El próximo despertar se detiene.')
464 return salida
465}
466
467/**
468 * La orden del próximo despertar de esta sesión: la issue que tiene, su primer paso sin hacer, el criterio y los
469 * archivos (la primera de la cola que no espere al dueño). Sale del fichero y de nada más. El paso no se inventa: sin `declarado[n].pasos`, o con todos hechos,
470 * la orden es parar. Es lo único que llega al modelo cuando el bucle despierta.
471 */
472function ordenDeSiempre(estado: Estado): { texto: string; parar: boolean; numero: number | null; paso: string | null } {
473 // Las vigentes de esta sesión, en el orden de la cola (las claves numéricas de un objeto salen por número, no
474 // por cola). La orden es la de la primera que no espere al dueño; si todas esperan, se para con el motivo.
475 const cola = estado.cola ?? []
476 const lugar = (n: string): number => (cola.indexOf(Number(n)) < 0 ? cola.length : cola.indexOf(Number(n)))
477 const mias = Object.entries(estado.asignaciones ?? {})
478 .filter(([, a]) => a.trabajador === 'claude' && a.estado === 'vigente')
479 .sort(([a], [b]) => lugar(a) - lugar(b) || Number(a) - Number(b))
480 const mia = mias.find(([n]) => !estado.declarado?.[n]?.espera) ?? mias[0]
481 const cierre = 'No recorras el tablero ni abras issues: termina el bucle (ScheduleWakeup con stop) y dilo en una línea.'
482 if (!mia) return { texto: `Orden: parar. Esta sesión no tiene una asignación vigente. ${cierre}`, parar: true, numero: null, paso: null }
483 const numero = Number(mia[0])
484 const d = estado.declarado?.[mia[0]]
485 if (d?.espera) return { texto: `Orden: parar. La #${numero} espera al dueño: ${d.espera}. ${cierre}`, parar: true, numero, paso: null }
486 const siguiente = (d?.pasos ?? []).find(x => !x.hecho)
487 if (!siguiente) {
488 const porque = d?.pasos?.length ? 'todos sus pasos están hechos' : 'no tiene pasos declarados (declarado.' + numero + '.pasos)'
489 return { texto: `Orden: parar. La #${numero} es de esta sesión, pero ${porque} en .claude/orquestacion.json: no queda un paso que cierre algo. ${cierre}`, parar: true, numero, paso: null }
490 }
491 const noTocar = estado.no_tocar?.length ? estado.no_tocar : NO_TOCAR_INICIAL
492 return {
493 texto: [
494 `Orden: issue #${numero}.`,
495 `Siguiente paso: ${siguiente.paso}`,
496 `Criterio: ${d?.criterio ?? `el que la issue #${numero} deja escrito`}`,
497 `Archivos: ${d?.archivos?.length ? d.archivos.join(', ') : 'sólo los que este paso exija'}. No toques: ${noTocar.join('; ')}.`,
498 'Haz sólo este paso. Al terminarlo, márcalo hecho en .claude/orquestacion.json; no lo des por hecho sin medirlo. Empujar, migrar y desplegar: sólo con la orden del dueño.',
499 ].join('\n'),
500 parar: false,
501 numero,
502 paso: siguiente.paso,
503 }
504}
505
506/** Lo que no salió asignado, en una línea por situación. */
507export function resto(s: Salida): string[] {
508 const de = (sit: Fila['situacion']): string => s.filas.filter(f => f.situacion === sit).map(f => `#${f.numero}`).join(', ')
509 const lineas: string[] = []
510 if (de('en cola')) lineas.push(`En cola: ${de('en cola')}.`)
511 if (de('espera al dueño')) lineas.push(`Espera al dueño: ${de('espera al dueño')}.`)
512 if (de('partir')) lineas.push(`Hay que partir antes de asignar: ${de('partir')}.`)
513 if (de('interina')) lineas.push(`Interinas, sin cerrar: ${de('interina')}.`)
514 if (de('épica')) lineas.push(`Épicas: ${de('épica')}.`)
515 return lineas
516}
517
518/** El mismo estado con la decisión nueva. No lleva la hora del router: sin cambio de decisión, el fichero no cambia. */
519export function conDecision(estado: Estado, s: Salida, github: string, abiertas: number | null, pendientes: string[]): Estado {
520 return {
521 ...estado,
522 cola: estado.cola?.length ? estado.cola : [...COLA_INICIAL],
523 no_tocar: estado.no_tocar?.length ? estado.no_tocar : [...NO_TOCAR_INICIAL],
524 asignaciones: s.asignaciones,
525 clasificacion: s.filas,
526 router: { github, issues_abiertas: abiertas, ...(pendientes.length ? { pendientes } : {}) },
527 }
528}
529
530// ── El control escucha: la orden sale de las señales ────────────────────────────────────────────────
531
532/** Un cuello de botella del index, tal como lo deja el guion en index.json. `datos` es lo que el control lee. */
533export type Senal = { tipo: string; gravedad: 'alta' | 'media'; que: string; desde?: string; datos?: Record<string, unknown> }
534
535/** Lo que una señal manda hacer, y por qué: la señal que lo causó y su número. */
536export type Causa = { regla: 'credencial' | 'remoto' | 'migracion' | 'produccion' | 'pieza' | 'a-medias'; gravedad: 'alta' | 'media'; clave: string; orden: string; porque: string }
537
538export type Orden = {
539 texto: string
540 parar: boolean
541 numero: number | null
542 paso: string | null
543 /** Las señales que mandan, en su orden de prioridad (como mucho tres). Vacío: la orden de siempre. */
544 causas: Causa[]
545 /** Qué señales mandan, sin horas ni duraciones: cambia sólo cuando cambia la orden, no cuando pasa el tiempo. */
546 firma: string
547}
548
549const PRIORIDAD: readonly Causa['regla'][] = ['credencial', 'remoto', 'migracion', 'produccion', 'pieza', 'a-medias']
550const MAXIMO_DE_CAUSAS = 3
551
552function tiempo(min: unknown): string {
553 if (typeof min !== 'number' || !Number.isFinite(min)) return 'un tiempo sin medir'
554 const m = Math.max(0, Math.round(min))
555 if (m < 60) return `${m} min`
556 if (m < 48 * 60) return `${Math.floor(m / 60)} h ${m % 60} min`
557 return `${Math.floor(m / 1440)} días`
558}
559
560/**
561 * Qué señales cambian la orden, en su orden de prioridad. Regla general: terminar antes que empezar.
562 * Una señal que no afecta a esta sesión (el árbol de otro, una issue ajena) no manda aquí.
563 */
564export function causasDe(estado: Estado, senales: readonly Senal[]): Causa[] {
565 const asignaciones = Object.entries(estado.asignaciones ?? {}).filter(([, a]) => a.estado === 'vigente')
566 const mias = new Set(asignaciones.filter(([, a]) => a.trabajador === 'claude').map(([n]) => Number(n)))
567 const vigentes = new Set(asignaciones.map(([n]) => Number(n)))
568 const causas: Causa[] = []
569 const texto = (x: unknown): string => (typeof x === 'string' ? x : '')
570 for (const s of senales) {
571 const d = s.datos ?? {}
572 if (s.tipo === 'credencial') {
573 causas.push({ regla: 'credencial', gravedad: 'alta', clave: 'github', orden: 'Parar lo que dependa de GitHub. Pedir la credencial al dueño ahora, con la prueba (código HTTP y hora). Seguir sólo con lo que no la necesite.', porque: `GitHub respondió ${typeof d.http === 'number' ? d.http : 401} a la credencial declarada${texto(d.hora) ? `, medido el ${texto(d.hora)}` : ''}` })
574 } else if (s.tipo === 'remoto' && d.sesion === true) {
575 causas.push({ regla: 'remoto', gravedad: 'alta', clave: texto(d.a) || 'remoto', orden: 'Antes de verificar o empujar: traer el remoto y volver a medir.', porque: `el remoto movió la rama principal (${texto(d.de) || 'lo que este clon conoce'} → ${texto(d.a) || 'otro commit'}) y esta sesión tiene trabajo debajo` })
576 } else if (s.tipo === 'migracion' && (d.sesion === true || (Array.isArray(d.issues) && d.issues.some(n => mias.has(Number(n)))))) {
577 causas.push({ regla: 'migracion', gravedad: 'alta', clave: texto(d.numero) || 'numero', orden: 'Reservar o renumerar antes de seguir; no encargar otra migración hasta que cuadre.', porque: `migración ${texto(d.numero)}: ${s.que}` })
578 } else if (s.tipo === 'produccion' && d.codigo === 'distinto') {
579 causas.push({ regla: 'produccion', gravedad: 'alta', clave: texto(d.principal) || 'produccion', orden: 'Desplegar, en orden: migraciones, API, web.', porque: `producción corre ${texto(d.produccion)} y la rama principal está en ${texto(d.principal)}; entre los dos cambió código de lo que se despliega` })
580 } else if (s.tipo === 'pieza' || s.tipo === 'piezas-viejas') {
581 const piezas = (s.tipo === 'pieza' ? [d] : Array.isArray(d.piezas) ? d.piezas : []) as Record<string, unknown>[]
582 for (const p of piezas) {
583 const deEstaSesion = p.principal === true || (typeof p.issue === 'number' && vigentes.has(p.issue))
584 const p95 = typeof p.p95_min === 'number' ? p.p95_min : null
585 const espera = typeof p.espera_min === 'number' ? p.espera_min : null
586 // Sin P95 (pocos empujes en el reflog) no hay con qué decir que espera más de lo habitual: no manda.
587 if (!deEstaSesion || p95 === null || espera === null || espera <= p95) continue
588 causas.push({ regla: 'pieza', gravedad: 'media', clave: texto(p.rama) || 'pieza', orden: `Integrar ${texto(p.rama) || 'la pieza terminada'} antes de construir nada nuevo.`, porque: `pieza terminada hace ${tiempo(espera)}; P95 = ${p95} min, n = ${typeof p.n === 'number' ? p.n : 'sin medir'}` })
589 }
590 } else if ((s.tipo === 'sin-confirmar' || s.tipo === 'stash') && d.externo === true && d.sin_moverse === true) {
591 const quien = texto(d.trabajador) || 'ese trabajador'
592 const que = [typeof d.sin_confirmar === 'number' && d.sin_confirmar > 0 ? `${d.sin_confirmar} ficheros sin confirmar` : '', typeof d.stash === 'number' && d.stash > 0 ? `${d.stash} entradas en el stash` : ''].filter(Boolean).join(' y ')
593 causas.push({ regla: 'a-medias', gravedad: 'media', clave: texto(d.rama) || quien, orden: `Preguntar al dueño por ${quien}; no reasignar.`, porque: `${quien} tiene ${que || 'trabajo a medias'} en ${texto(d.rama) || 'su rama'} y no se mueve hace ${tiempo(d.sin_moverse_min)}` })
594 }
595 }
596 return causas.sort((a, b) => PRIORIDAD.indexOf(a.regla) - PRIORIDAD.indexOf(b.regla)).slice(0, MAXIMO_DE_CAUSAS)
597}
598
599/**
600 * La orden del próximo despertar. Sin señales que manden, es la de siempre: la issue de esta sesión, su primer
601 * paso sin hacer, el criterio y los archivos, o parar. Con señales, la orden es resolverlas primero, en su orden
602 * de prioridad y como mucho tres; cada una lleva debajo la señal que la causó y su número. El router sigue sin
603 * entrada ni salida: las señales (los cuellos de index.json) las pasa quien llama.
604 */
605export function orden(estado: Estado, senales: readonly Senal[] = []): Orden {
606 const base = ordenDeSiempre(estado)
607 const causas = causasDe(estado, senales)
608 if (!causas.length) return { ...base, causas, firma: `siempre:${base.numero ?? 'parar'}:${base.paso ?? ''}` }
609 const despues = base.parar ? 'Después, si no queda nada de lo anterior: parar.' : `Después, y sólo con lo anterior resuelto: la issue #${base.numero}, paso «${base.paso}».`
610 return {
611 texto: ['Orden: terminar antes que empezar.', ...causas.flatMap((c, i) => [`${i + 1}. ${c.orden}`, ` porque: ${c.porque}`]), despues].join('\n'),
612 parar: false,
613 numero: base.numero,
614 paso: base.paso,
615 causas,
616 firma: causas.map(c => `${c.gravedad}:${c.regla}:${c.clave}`).join('|'),
617 }
618}
619hooks/panel.ts 175 lines1// Lo que el panel y el control leen del index, sin entrada ni salida: recibe index.json y diario.json ya leídos
2// (los escribe el guion sin modelo, herramientas/indice.mjs y diario.mjs) y devuelve las líneas del panel y las
3// señales para el control. El mod no vuelve a calcular nada de esto: una sola fuente de verdad. Si la foto es
4// vieja, no se muestra nada como vigente y el control no recibe señales.
5
6import type { Lectura } from '../types'
7import type { Senal } from './router'
8
9export const MIN_FOTO_VIEJA = 10
10export const MEDIAS_A_LA_VISTA = 5
11const MIN = 60_000
12
13type Dato = Record<string, any>
14
15export function hace(min: number | null | undefined): string {
16 if (min === null || min === undefined || !Number.isFinite(min)) return 'un tiempo sin medir'
17 const m = Math.max(0, Math.round(min))
18 if (m < 60) return `${m} min`
19 if (m < 48 * 60) return `${Math.floor(m / 60)} h ${m % 60} min`
20 return `${Math.floor(m / 1440)} días`
21}
22
23const p2 = (n: number): string => String(n).padStart(2, '0')
24/** La hora local, «14:05»; con la fecha delante si no es de hoy. */
25export function horaLocal(ms: number, ahoraMs: number): string {
26 const d = new Date(ms)
27 const hoy = new Date(ahoraMs)
28 const hora = `${p2(d.getHours())}:${p2(d.getMinutes())}`
29 return d.toDateString() === hoy.toDateString() ? hora : `${p2(d.getDate())}-${p2(d.getMonth() + 1)} ${hora}`
30}
31
32/** Dos cifras significativas y coma decimal: «2,0 d», «7,5 d», «25 d», «40 min». */
33export function enDias(dias: number): string {
34 const dos = (x: number): number => Number(x.toPrecision(2))
35 if (dias < 1 / 24) return `${Math.round(dias * 24 * 60)} min`
36 const [valor, unidad] = dias < 1 ? [dos(dias * 24), 'h'] : [dos(dias), 'd']
37 return `${valor < 10 ? valor.toFixed(1).replace('.', ',') : String(Math.round(valor))} ${unidad}`
38}
39
40const barras = (ruta: string): string => ruta.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
41
42/** El repositorio del index que corresponde a la carpeta de esta sesión: su raíz, uno de sus árboles o una carpeta de dentro. */
43function repositorioDe(index: Dato, raiz: string): Dato | null {
44 const aqui = barras(raiz)
45 for (const r of Array.isArray(index.repositorios) ? index.repositorios : []) {
46 if (!r || r.error || typeof r.raiz !== 'string') continue
47 const suya = barras(r.raiz)
48 const arboles = [...(Array.isArray(r.arboles) ? r.arboles : []), ...(Array.isArray(r.efimeros?.inactivos) ? r.efimeros.inactivos : [])]
49 if (aqui === suya || aqui.startsWith(`${suya}/`) || arboles.some(a => typeof a?.ruta === 'string' && barras(a.ruta) === aqui)) return r
50 }
51 return null
52}
53
54const VACIA: Lectura = { instalado: false, vigente: false, foto_ms: null, repositorio: null, senales: [], abiertas: null, fuera: [], caducadas: [], construidas: null, primera_alta: null, foto: '', vieja: '', cuellos: [], cuellos_resto: '', quien_titulo: '', quien: [], declarado: [], flujo: [], coordinacion: [] }
55
56/**
57 * Lee la foto. `index` y `diario`: los JSON ya leídos, o null si no están. `raiz`: la carpeta de la sesión.
58 * Con la foto vieja (más de 10 minutos) devuelve sólo el aviso: ni cuellos, ni señales, ni cifras.
59 */
60export function leerFoto(index: unknown, diario: unknown, raiz: string, ahoraMs: number): Lectura {
61 if (!index || typeof index !== 'object') return VACIA
62 const x = index as Dato
63 const fotoMs = typeof x.foto_ms === 'number' ? x.foto_ms : null
64 if (fotoMs === null) return VACIA
65 const edad = (ahoraMs - fotoMs) / MIN
66 const repo = repositorioDe(x, raiz)
67 const nombre = repo ? String(repo.nombre) : null
68 const base: Lectura = { ...VACIA, instalado: true, foto_ms: fotoMs, repositorio: nombre }
69 if (edad > MIN_FOTO_VIEJA) {
70 return { ...base, vieja: `El index no se está actualizando: la última foto es de las ${horaLocal(fotoMs, ahoraMs)}, hace ${hace(edad)}. Nada de lo que medía se muestra como vigente.` }
71 }
72 const foto = `Foto de las ${horaLocal(fotoMs, ahoraMs)} · hace ${edad < 1.5 ? `${Math.max(0, Math.round(edad * 60))} s` : hace(edad)} · ${nombre ?? 'esta carpeta no está entre los repositorios del index'}`
73 if (!repo) return { ...base, vigente: true, foto, vieja: 'Esta carpeta no está entre los repositorios que mide el index: «node herramientas/instalar.mjs --raiz <este repositorio>» la añade.' }
74
75 // Cuellos de botella de este repositorio: los de gravedad alta, todos; los de media, hasta cinco; el resto, contado.
76 const suyos = (Array.isArray(x.cuellos) ? x.cuellos : []).filter((c: Dato) => c && c.repositorio === repo.nombre && typeof c.que === 'string')
77 const senales: Senal[] = suyos.map((c: Dato) => ({ tipo: String(c.tipo), gravedad: c.gravedad === 'alta' ? 'alta' : 'media', que: String(c.que), desde: typeof c.desde === 'string' ? c.desde : undefined, datos: c.datos && typeof c.datos === 'object' ? c.datos : {} }))
78 const altas = senales.filter(s => s.gravedad === 'alta')
79 const medias = senales.filter(s => s.gravedad === 'media')
80 const cuellos = [...altas.map(s => ({ gravedad: 'ALTA', texto: s.que })), ...medias.slice(0, MEDIAS_A_LA_VISTA).map(s => ({ gravedad: 'MEDIA', texto: s.que }))]
81 const sobran = medias.length - Math.min(medias.length, MEDIAS_A_LA_VISTA)
82
83 // Quién está en qué, medido: una línea por árbol con trabajo vivo.
84 const arboles: Dato[] = Array.isArray(repo.arboles) ? repo.arboles : []
85 const vivos = arboles.filter(a => typeof a.estado === 'string' && !a.estado.startsWith('sin cambios locales') && !a.estado.startsWith('la carpeta'))
86 const quien = vivos.map(a => {
87 const sucio = a.sin_confirmar ? `${a.sin_confirmar} ${a.sin_confirmar === 1 ? 'fichero' : 'ficheros'} sin confirmar${a.mas_reciente ? `, el más reciente hace ${hace((ahoraMs - a.mas_reciente.ms) / MIN)}` : ''}` : '0 ficheros sin confirmar'
88 return `${a.forma ?? ''} ${a.estado} · ${a.principal ? 'sesión principal' : a.trabajador ?? (a.efimero ? 'agente efímero' : 'trabajador sin identificar')} · ${a.issue === null || a.issue === undefined ? 'sin issue en la rama' : `#${a.issue}`} · ${a.rama ?? 'HEAD suelto'} · ${sucio} · ${a.delante === null || a.delante === undefined ? 'por delante: NO MEDIDO' : `${a.delante} ${a.delante === 1 ? 'commit' : 'commits'} por delante`}`.trim()
89 })
90
91 // Lo declarado que lo medido contradice: no se muestra como vigente, se marca.
92 const g: Dato = repo.github ?? {}
93 const abiertas: number[] | null = Array.isArray(g.abiertas_numeros) ? g.abiertas_numeros : null
94 const control: Dato = repo.control ?? {}
95 const fuera: string[] = Array.isArray(control.fuera) ? control.fuera.map(String) : []
96 const enEsperaCaducada = new Set<number>(Array.isArray(control.esperas_caducadas) ? control.esperas_caducadas : [])
97 const caducadas: number[] = []
98 const declarado: string[] = []
99 for (const a of Array.isArray(control.asignaciones) ? control.asignaciones : []) {
100 const quienEs = String(a.quien ?? a.trabajador)
101 if (abiertas && !abiertas.includes(a.issue)) {
102 caducadas.push(a.issue)
103 declarado.push(`declarado, caducado: la #${a.issue} figura en manos de ${quienEs} y ya no está abierta en GitHub`)
104 } else if (fuera.includes(String(a.trabajador))) {
105 caducadas.push(a.issue)
106 declarado.push(`declarado, caducado: la #${a.issue} figura en manos de ${quienEs}, que está fuera`)
107 } else if (enEsperaCaducada.has(a.issue)) {
108 declarado.push(`declarado, caducado: la #${a.issue} figura en espera y su rama ya llegó a la principal`)
109 }
110 }
111
112 // Flujo, con indicadores reales. Lo que falta, NO MEDIDO con su motivo.
113 const motivoGitHub = x.sin_red === true ? 'la foto se tomó sin red' : g.credencial?.estado === 'sin declarar' ? 'el repositorio no declara credencial de GitHub' : g.credencial?.estado === 'rechazada (401)' ? 'GitHub rechazó la credencial (401)' : (g.credencial?.nota ?? 'GitHub no respondió')
114 const flujo: string[] = []
115 if (Number.isInteger(g.issues_abiertas)) {
116 const b = g.no_avanzan
117 flujo.push(`Issues abiertas: ${g.issues_abiertas}${b ? `; ${b.total} de ${b.denominador} no pueden avanzar solas (${b.espera} por espera declarada, ${b.etiqueta} por etiqueta de bloqueo, ${b.xl} de peso XL sin partir)` : ''} · cerradas hoy: ${Number.isInteger(g.cerradas_hoy) ? g.cerradas_hoy : 'NO MEDIDO'} · GitHub, en esta foto`)
118 } else flujo.push(`Issues abiertas y cerradas hoy: NO MEDIDO (${motivoGitHub})`)
119 const d: Dato | null = diario && typeof diario === 'object' ? ((diario as Dato).repositorios?.[barras(String(repo.raiz))] ?? null) : null
120 const calculado = typeof (diario as Dato | null)?.calculado_ms === 'number' ? `cálculo diario de las ${horaLocal((diario as Dato).calculado_ms, ahoraMs)}` : 'cálculo diario'
121 const sinDiario = d?.motivo ?? (g.credencial?.estado === 'sin declarar' ? 'el repositorio no declara credencial de GitHub, y el cálculo diario la necesita' : 'el cálculo diario todavía no se hizo: «node indice.mjs --diario»')
122 if (d?.medido) {
123 const e = d.entrega
124 flujo.push(e.p50_dias === null ? `Tiempo de entrega: NO MEDIDO (n = ${e.n} issues construidas, hacen falta 10 para una mediana)` : `Tiempo de entrega: la mitad de las construidas, menos de ${enDias(e.p50_dias)}${e.p85_dias === null ? '; P85 NO MEDIDO (hacen falta 20)' : `; 85 de cada 100, menos de ${enDias(e.p85_dias)}`} · n = ${e.n} issues construidas · ${calculado}`)
125 } else flujo.push(`Tiempo de entrega: NO MEDIDO (${sinDiario})`)
126 const integ: Dato = repo.integracion ?? {}
127 if (integ.medido) {
128 const s = integ.sin_integrar
129 flujo.push(`Sin integrar: ${s.total} ${s.total === 1 ? 'commit escrito' : 'commits escritos'} y sin llegar a ${repo.origen?.rama ?? 'la principal'}${s.de_hoy ? `; ${s.de_hoy} de hoy, el más viejo de hoy hace ${hace((ahoraMs - s.mas_viejo_de_hoy_ms) / MIN)}` : '; 0 de hoy'} · n = ${s.ramas_miradas} ramas miradas`)
130 const u = integ.umbral ?? {}
131 const umbral = u.p95_min === null || u.p95_min === undefined ? `P95 NO MEDIDO (n = ${u.n ?? 0} huecos, hacen falta 40)` : `P95 = ${u.p95_min} min, n = ${u.n}`
132 flujo.push(integ.hueco_min === null || integ.hueco_min === undefined ? `Hueco desde el último empuje: sin commits de hoy esperando, no hay hueco que medir · ${umbral}` : `Hueco desde el último empuje: ${hace(integ.hueco_min)}${integ.sobre_el_umbral ? ', MAYOR que lo habitual' : ''} · ${umbral}`)
133 } else flujo.push('Sin integrar y hueco de empujes: NO MEDIDO (este clon no tiene rama principal remota)')
134 flujo.push(d?.medido ? `Cierres en bloque: ${d.bloques.cierres_en_bloque} de ${d.bloques.cierres} cierres, en ${d.bloques.bloques} ${d.bloques.bloques === 1 ? 'bloque' : 'bloques'} · ${calculado}` : `Cierres en bloque: NO MEDIDO (${sinDiario})`)
135
136 // Coordinación.
137 const k: Dato | null = repo.incidentes ?? null
138 const coordinacion = [k ? `Incidentes de hoy: ${k.hoy}${k.hoy ? `; ${k.en_causa_repetida} de ${k.hoy} son de una causa que se repite` : ''} · n = ${k.dias_con_registro} ${k.dias_con_registro === 1 ? 'día' : 'días'} de registro` : 'Incidentes de hoy: NO MEDIDO (el repositorio no tiene registro de incidentes)']
139 const dec: Dato | null = (diario as Dato | null)?.decisiones ?? null
140 if (dec && dec.cambios > 0) coordinacion.push(`Señales que cambiaron la orden: ${dec.senales} (${dec.altas} de gravedad alta); resueltas ${dec.resueltas}; reaparecieron ${dec.reaparecidas} · ${dec.dias >= 7 ? `${dec.dias} días de registro` : `tendencia NO MEDIDO (n = ${dec.dias} días de registro, hacen falta 7): sólo conteo`}`)
141
142 // Una foto escrita por un guion anterior no trae el estado de cada árbol ni los datos de las señales: se dice.
143 const version = String(x.version ?? '')
144 const antigua = /^(2\.(3|[4-9]|\d\d)\.|[3-9]\.|\d\d)/.test(version) ? '' : `La foto la escribió el guion ${version || 'de una versión anterior'} y este panel necesita la 2.3.0 o posterior: vuelve a correr «node herramientas/instalar.mjs --raiz <este repositorio>» para actualizarlo. Hasta entonces faltan el estado de cada árbol y las señales del control.`
145
146 return {
147 ...base,
148 vigente: true,
149 vieja: antigua,
150 senales,
151 abiertas,
152 fuera,
153 caducadas,
154 construidas: d?.medido ? d.entrega.n : null,
155 primera_alta: d?.medido ? String(d.universo.primera_alta) : null,
156 foto,
157 cuellos,
158 cuellos_resto: sobran > 0 ? `y ${sobran} más de gravedad media, en el index` : '',
159 quien_titulo: `Quién está en qué, medido · ${vivos.length} de ${arboles.length} árboles con trabajo vivo`,
160 quien,
161 declarado,
162 flujo,
163 coordinacion,
164 }
165}
166
167/** «≈ X USD por issue construida», con una cifra significativa y lo que entra arriba y abajo. Con menos de 30, NO MEDIDO. */
168export function costoPorConstruida(usdDelProyecto: number | null, desde: string, l: Lectura): string {
169 if (usdDelProyecto === null) return ''
170 if (l.construidas === null) return 'Costo por issue construida: NO MEDIDO (falta el cálculo diario, que cuenta las issues con algún commit que las nombra)'
171 if (l.construidas < 30) return `Costo por issue construida: NO MEDIDO (n = ${l.construidas} issues construidas, hacen falta 30)`
172 const una = Number((usdDelProyecto / l.construidas).toPrecision(1))
173 return `≈ ${String(una).replace('.', ',')} USD por issue construida, acumulado (≈ ${Math.round(usdDelProyecto)} USD de la factura repartidos a este proyecto${desde ? ` desde el ${desde}` : ''} ÷ ${l.construidas} issues construidas${l.primera_alta ? ` desde el ${l.primera_alta}` : ''}). Subestima: sólo cuenta a Claude, no a los otros agentes`
174}
175hooks/indice.ts 214 lines1// El index de `consumo`, sin entrada ni salida: recibe lo ya leído y devuelve las fichas y sus páginas.
2// Son dos salidas y las arman dos funciones distintas, a propósito:
3// · `paginaDeTareas` — el index privado: una ficha por repositorio del reparto, con número de issue, quién la
4// tiene, el paso sin hacer y qué hizo el último despertar. No lleva títulos de issues ni la cola.
5// · `fichaDeProduccion` — la ficha pública: recibe SÓLO { commit, bien, cuando }. No recibe el estado de
6// orquestación, así que no puede filtrar backlog aunque alguien se equivoque al llamarla.
7// La página muestra la foto y nada más: HTML estático, sin guiones y sin estilos externos.
8
9import type { Estado } from './router'
10import { NOMBRE, orden } from './router'
11
12/** Lo que el hook del despertar deja guardado, por raíz de repositorio, en el almacén del mod. */
13export type Despertar = {
14 /** Milisegundos de la época. */
15 cuando: number
16 resultado: 'orden' | 'parar'
17 numero: number | null
18 paso: string | null
19 /** Pasos marcados `hecho` en todo el fichero cuando despertó. */
20 pasosHechos: number
21 /** Había más pasos hechos que en el despertar anterior. */
22 cerro: boolean
23}
24
25export type Hizo = 'cerró algo' | 'dio una orden' | 'paró' | 'sin despertares registrados'
26
27export type Ficha =
28 | { nombre: string; control: false; estado: 'sin control'; foto: string }
29 | {
30 nombre: string
31 control: true
32 /** La issue en curso de esa sesión, o null si no tiene ninguna. */
33 issue: number | null
34 quien: string | null
35 /** El primer paso sin hacer, o «no hay paso». */
36 paso: string
37 despertar: Hizo
38 despertar_cuando: string | null
39 foto: string
40 }
41
42export const SIN_PASO = 'no hay paso'
43
44/** Hora legible y sin ambigüedad de zona: «2026-05-28 20:26 UTC». */
45export function instante(ms: number): string {
46 const iso = new Date(ms).toISOString()
47 return `${iso.slice(0, 10)} ${iso.slice(11, 16)} UTC`
48}
49
50/** Cuántos pasos hay marcados `hecho` en todo el fichero. Se cuenta el fichero entero para que cerrar el último
51 * paso de una issue y pasar a la siguiente no parezca un retroceso. */
52export function pasosHechos(estado: Estado): number {
53 let n = 0
54 for (const d of Object.values(estado.declarado ?? {})) n += (d?.pasos ?? []).filter(p => p?.hecho === true).length
55 return n
56}
57
58/** El registro de un despertar, a partir del estado ya decidido y del registro anterior de esa misma raíz. */
59export function registroDeDespertar(estado: Estado, anterior: Despertar | null, ahoraMs: number): Despertar {
60 const o = orden(estado)
61 const hechos = pasosHechos(estado)
62 return {
63 cuando: ahoraMs,
64 resultado: o.parar ? 'parar' : 'orden',
65 numero: o.numero,
66 paso: o.paso,
67 pasosHechos: hechos,
68 cerro: anterior !== null && hechos > anterior.pasosHechos,
69 }
70}
71
72/** Lo guardado puede venir de otra versión o estar a medias: sólo vale si tiene la forma entera. */
73export function comoDespertar(x: unknown): Despertar | null {
74 if (!x || typeof x !== 'object') return null
75 const d = x as Record<string, unknown>
76 if (typeof d.cuando !== 'number' || typeof d.pasosHechos !== 'number' || (d.resultado !== 'orden' && d.resultado !== 'parar')) return null
77 return {
78 cuando: d.cuando,
79 resultado: d.resultado,
80 numero: typeof d.numero === 'number' ? d.numero : null,
81 paso: typeof d.paso === 'string' ? d.paso : null,
82 pasosHechos: d.pasosHechos,
83 cerro: d.cerro === true,
84 }
85}
86
87function queHizo(d: Despertar | null): Hizo {
88 if (!d) return 'sin despertares registrados'
89 if (d.cerro) return 'cerró algo'
90 return d.resultado === 'orden' ? 'dio una orden' : 'paró'
91}
92
93/**
94 * La ficha de un repositorio del reparto. `estado` es su .claude/orquestacion.json ya leído, o null si no lo
95 * tiene: entonces sale «sin control» y nada más. Del estado sólo se toma el número de la issue en curso, quién
96 * la tiene y el primer paso sin hacer; ni títulos, ni cola, ni clasificación.
97 */
98export function ficha(nombre: string, estado: Estado | null, despertar: Despertar | null, ahoraMs: number): Ficha {
99 const foto = instante(ahoraMs)
100 if (!estado) return { nombre, control: false, estado: 'sin control', foto }
101 const numero = orden(estado).numero
102 const asignacion = numero === null ? undefined : estado.asignaciones?.[String(numero)]
103 const siguiente = numero === null ? undefined : (estado.declarado?.[String(numero)]?.pasos ?? []).find(p => !p.hecho)
104 return {
105 nombre,
106 control: true,
107 issue: numero,
108 quien: asignacion ? NOMBRE[asignacion.trabajador] ?? String(asignacion.trabajador) : null,
109 paso: siguiente?.paso ? String(siguiente.paso) : SIN_PASO,
110 despertar: queHizo(despertar),
111 despertar_cuando: despertar ? instante(despertar.cuando) : null,
112 foto,
113 }
114}
115
116const escapar = (t: string): string => t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
117
118const CABEZA = (titulo: string): string =>
119 `<!doctype html>\n<html lang="es">\n<head>\n<meta charset="utf-8">\n<meta name="viewport" content="width=device-width, initial-scale=1">\n<title>${escapar(titulo)}</title>\n<style>body{font-family:system-ui,sans-serif;max-width:46rem;margin:2rem auto;padding:0 1rem;line-height:1.5}section{border:1px solid;padding:0 1rem;margin:1rem 0}dt{font-weight:bold}dd{margin:0 0 .5rem 0}</style>\n</head>\n<body>\n`
120
121function seccionDeFicha(f: Ficha): string {
122 if (!f.control) return `<section>\n<h2>${escapar(f.nombre)}</h2>\n<p>sin control</p>\n</section>\n`
123 const issue = f.issue === null ? 'ninguna en curso' : `#${f.issue}${f.quien ? ` · la tiene ${escapar(f.quien)}` : ''}`
124 const despertar = `${f.despertar}${f.despertar_cuando ? ` (${f.despertar_cuando})` : ''}`
125 return [
126 '<section>',
127 `<h2>${escapar(f.nombre)}</h2>`,
128 '<dl>',
129 `<dt>Issue en curso</dt><dd>${issue}</dd>`,
130 `<dt>Paso sin hacer</dt><dd>${escapar(f.paso)}</dd>`,
131 `<dt>Último despertar</dt><dd>${escapar(despertar)}</dd>`,
132 `<dt>Foto</dt><dd>${f.foto}</dd>`,
133 '</dl>',
134 '</section>',
135 '',
136 ].join('\n')
137}
138
139/** El index de tareas, privado: la página y su JSON. Se queda en la máquina; lleva números de issue y pasos. */
140export function paginaDeTareas(fichas: Ficha[], ahoraMs: number): { html: string; json: string } {
141 const foto = instante(ahoraMs)
142 const html =
143 CABEZA('Index de tareas') +
144 `<h1>Index de tareas</h1>\n<p>Foto del ${foto}. Privado: no se publica. Muestra, no decide: quien asigna y da la orden es el control.</p>\n` +
145 (fichas.length ? fichas.map(seccionDeFicha).join('') : '<p>Ningún repositorio entra en el reparto todavía.</p>\n') +
146 '</body>\n</html>\n'
147 return { html, json: `${JSON.stringify({ foto, fichas }, null, 2)}\n` }
148}
149
150/** Lo único que la ficha pública conoce: lo que la URL de salud publica, y la hora de la foto. */
151export type Salud = { commit: string | null; bien: boolean; cuando: number }
152
153/**
154 * Lee lo que respondió la URL de salud. `commit`: el campo `commit` si es un SHA (hexadecimal de 7 a 40); cualquier
155 * otra cosa es null y no se refleja. `bien`: `status` o `estado` dicen «ok», o `ok` es true. Una respuesta vacía,
156 * que no es JSON o que no dice nada de eso, no está bien.
157 */
158export function saludDe(respuesta: string): { commit: string | null; bien: boolean } {
159 let d: Record<string, unknown>
160 try {
161 const leido = JSON.parse(respuesta) as unknown
162 if (!leido || typeof leido !== 'object') return { commit: null, bien: false }
163 d = leido as Record<string, unknown>
164 } catch {
165 return { commit: null, bien: false }
166 }
167 const commit = typeof d.commit === 'string' && /^[0-9a-f]{7,40}$/i.test(d.commit) ? d.commit.toLowerCase() : null
168 const dicho = [d.status, d.estado].find(x => typeof x === 'string') as string | undefined
169 return { commit, bien: d.ok === true || (dicho ?? '').trim().toLowerCase() === 'ok' }
170}
171
172/**
173 * La ficha de producción, pública: commit publicado, salud y hora. Se puede copiar a un sitio público porque no
174 * recibe nada más: ni issues, ni pasos, ni nombres, ni cola.
175 */
176export function fichaDeProduccion(s: Salud): { html: string; json: string } {
177 const foto = instante(s.cuando)
178 const commit = s.commit && /^[0-9a-f]{7,40}$/.test(s.commit) ? s.commit : null
179 const salud = s.bien ? 'bien' : 'con problemas o sin respuesta'
180 const html =
181 CABEZA('Producción') +
182 [
183 '<h1>Producción</h1>',
184 '<dl>',
185 `<dt>Commit publicado</dt><dd>${commit ? `<code>${commit}</code>` : 'no publicado'}</dd>`,
186 `<dt>Salud</dt><dd>${salud}</dd>`,
187 `<dt>Foto</dt><dd>${foto}</dd>`,
188 '</dl>',
189 '</body>',
190 '</html>',
191 '',
192 ].join('\n')
193 return { html, json: `${JSON.stringify({ commit, bien: s.bien === true, foto }, null, 2)}\n` }
194}
195
196/** El nombre de un proyecto, reducido a lo que cabe sin riesgo en un nombre de archivo. */
197export function nombreDeArchivo(nombre: string): string {
198 return (
199 nombre
200 .normalize('NFD')
201 .replace(/[̀-ͯ]/g, '')
202 .toLowerCase()
203 .replace(/[^a-z0-9]+/g, '-')
204 .replace(/^-+|-+$/g, '')
205 .slice(0, 60) || 'proyecto'
206 )
207}
208
209/** Sólo http(s): la URL va a `curl` como argumento y no puede parecer una opción. */
210export function urlDeSalud(estado: Estado | null): string | null {
211 const url = estado?.produccion?.salud
212 return typeof url === 'string' && /^https?:\/\/[^\s]+$/i.test(url) ? url : null
213}
214types/index.d.ts 256 lines1export type Conteo = {
2 llamadas: number
3 input_tokens: number
4 output_tokens: number
5 cache_read_input_tokens: number
6 cache_creation_input_tokens: number
7 usd: number
8}
9
10export type FilaModelo = Conteo & { modelo: string }
11
12export type FilaTarea = {
13 titulo: string
14 veces: number
15 llamadas: number
16 output_tokens: number
17 cache_read_input_tokens: number
18 cache_creation_input_tokens: number
19 usd: number
20 modelo: string
21 /** Segundos desde la época del primer y último mensaje de la tarea, o null. */
22 inicio: number | null
23 fin: number | null
24 minutos: number | null
25 /** Solo en la tarea actual: minutos desde su última respuesta. */
26 hace_min?: number | null
27}
28
29export type Despertador = {
30 razon: string
31 segundos: number | null
32 parar: boolean
33 noop: boolean
34 cuando: number | null
35 proximo?: number
36 faltan_min?: number
37}
38
39export type Ritmo = {
40 cubos_usd: number[]
41 ancho_cubo_min: number
42 usd_ultimos_30_min: number
43 usd_por_hora: number
44}
45
46export type Resumen = {
47 transcripcion: string
48 /** La sesión acaba de abrirse y el motor aún no escribió la transcripción: el resumen va vacío. */
49 sin_transcripcion?: boolean
50 ahora: number
51 inicio_sesion: number | null
52 ultima_respuesta: number | null
53 total: Conteo
54 por_modelo: FilaModelo[]
55 por_tarea: FilaTarea[]
56 por_herramienta: Record<string, number>
57 subagentes: string[]
58 actual: FilaTarea | null
59 despertador: Despertador | null
60 ritmo: Ritmo
61 nota_precios: string
62}
63
64export type Cobertura = {
65 /** Porcentaje total de líneas cubiertas, o null si no hay dato. */
66 total: number | null
67 /** 'lista' | 'corriendo' | 'sin-dato' | 'no-aplica' | 'error' */
68 estado: string
69 /** Hora de la última medición o intento, en milisegundos. */
70 cuando: number | null
71 /** Última línea útil de la herramienta, recortada. */
72 nota: string
73}
74
75export type Sesion = {
76 /** Costo según el libro del propio motor (/cost), en USD; null si no lo da. */
77 usdMotor: number | null
78 /** Porcentaje del contexto ocupado, o null. */
79 contextoPct: number | null
80 /** Tokens del contexto en la última respuesta, o null. */
81 contextoTokens: number | null
82 /** Tamaño de la ventana de contexto en tokens, o null. */
83 ventana: number | null
84 /** Cuándo empezó la sesión, en milisegundos, o null. */
85 inicio: number | null
86 /** Hora de la última actualización, en milisegundos. */
87 actualizado: number | null
88 /** Error de la última actualización, o ''. */
89 error: string
90}
91
92export type IssueGitHub = { number: number; title: string; labels: string[]; updatedAt: string }
93export type PrGitHub = { number: number; title: string; isDraft: boolean; updatedAt: string }
94
95export type GitHub = {
96 repo: string
97 cuando: number | null
98 issues: IssueGitHub[]
99 prs: PrGitHub[]
100 error: string
101}
102
103export type SemanaAvance = {
104 /** Lunes de la semana, AAAA-MM-DD (UTC). */
105 lunes: string
106 creadas: number
107 /** Issues cerradas como hechas esa semana, sin contar las descartadas. */
108 hechas: number
109 descartadas: number
110 /** Issues abiertas al terminar la semana. */
111 abiertas_fin: number
112 /** Parte de la factura mensual que el proyecto consumió esa semana, en USD. */
113 usd_factura: number
114 /** Porcentaje del consumo del proyecto en la ventana que cayó en esa semana. */
115 pct_del_proyecto: number
116}
117
118export type ProyectoAvance = {
119 /** Carpeta del proyecto en ~/.claude/projects, en minúsculas y con sus worktrees sumados. */
120 clave: string
121 sesiones: number
122 prompts: number
123 llamadas: number
124 /** Parte del consumo de todos los proyectos de esta máquina, en porcentaje. */
125 pct_del_total: number
126 /** Esa parte aplicada a la factura mensual, en USD. */
127 usd_factura: number
128 pct_subagentes: number
129 /** Porcentaje del consumo que es releer contexto (caché leída). */
130 pct_relectura: number
131 despertares: number
132 /** Despertares del bucle que terminaron sin cambiar nada (noop). */
133 despertares_vacios: number
134 agentes: number
135}
136
137export type RepoAvance = {
138 nombre: string
139 /** Con qué cuenta de gh se leyó. */
140 via: string
141 error: string
142 abiertas: number
143 creadas: number
144 hechas: number
145 descartadas: number
146 /** Parte de la factura del proyecto dividida entre las issues cerradas, o null si no hubo. */
147 usd_por_issue: number | null
148}
149
150export type Avance = {
151 /** 'sin-dato' | 'midiendo' | 'lista' | 'error' */
152 estado: string
153 /** Hora de la última medición o intento, en milisegundos. */
154 cuando: number | null
155 error: string
156 /** Primer día de la ventana medida, AAAA-MM-DD, o ''. */
157 desde: string
158 /** Factura mensual sobre la que se reparte, en USD. */
159 factura: number
160 proyecto: ProyectoAvance | null
161 repo: RepoAvance | null
162 semanas: SemanaAvance[]
163 /** Los proyectos de esta máquina que entran en el reparto. `raiz`: su carpeta, o '' si no se pudo deducir. */
164 reparto: { nombre: string; pct: number; usd: number; raiz?: string }[]
165 /** Ruta del index de tareas (privado) que se escribió al medir, o '' si no se escribió. */
166 index: string
167}
168
169/** La última decisión del control, en frases: quién tiene cada issue y qué hará el próximo despertar. */
170export type Control = {
171 cuando: number | null
172 /** false: el repositorio de trabajo no tiene .claude/orquestacion.json y el control no decide en él. */
173 activo: boolean
174 /** «ok» o por qué no se pudieron leer las issues. */
175 github: string
176 /** Quién tiene tomado el candado de suites, o vacío. */
177 candado: string
178 frases: string[]
179 resto: string[]
180 avisos: string[]
181 /** La orden del próximo despertar de esta sesión, con las cifras de la última foto: es la que pinta el panel. */
182 orden: string
183 /**
184 * La misma orden tal como quedó la última vez que CAMBIÓ (cambiaron las señales que mandan, no la hora): es la
185 * que va al prompt de sistema, para no romper su caché en cada foto.
186 */
187 ordenPrompt: string
188 /** Qué señales mandan, sin horas ni duraciones. */
189 firma: string
190 /** Cuántas señales del index cambian la orden (0: la orden de siempre). */
191 causas: number
192}
193
194/** Una señal del index (un cuello de botella), como la lee el control. */
195export type SenalDelIndex = { tipo: string; gravedad: 'alta' | 'media'; que: string; desde?: string; datos?: Record<string, unknown> }
196
197/** Lo que el panel y el control leen de index.json y diario.json. Con la foto vieja, `vigente` es false y todo va vacío. */
198export type Lectura = {
199 /** false: no hay index.json que leer. */
200 instalado: boolean
201 /** La foto tiene diez minutos o menos. */
202 vigente: boolean
203 foto_ms: number | null
204 /** El repositorio del index que corresponde a esta sesión, o null si esta carpeta no está en él. */
205 repositorio: string | null
206 senales: SenalDelIndex[]
207 /** Los números de las issues abiertas, o null si GitHub no se midió. */
208 abiertas: number[] | null
209 fuera: string[]
210 /** Issues que lo declarado da por asignadas y lo medido contradice. */
211 caducadas: number[]
212 /** Issues construidas (con algún commit que las nombra), del cálculo diario; null si no lo hay. */
213 construidas: number | null
214 primera_alta: string | null
215 foto: string
216 /** El aviso de foto vieja, o de que esta carpeta no está en el index. */
217 vieja: string
218 cuellos: { gravedad: string; texto: string }[]
219 cuellos_resto: string
220 quien_titulo: string
221 quien: string[]
222 declarado: string[]
223 flujo: string[]
224 coordinacion: string[]
225}
226
227/** El index vivo: lo que dejó la última corrida del guion sin modelo (herramientas/indice.mjs), si está instalado. */
228export type Vivo = {
229 /** null: todavía no se miró. false: el guion no está instalado en la carpeta del index. */
230 instalado: boolean | null
231 /** La carpeta del index, o '' si no se pudo deducir. */
232 carpeta: string
233 /** El primer cuello de botella, en una línea, o '' si no hay ninguno. */
234 cuello: string
235 cuellos: number
236 /** Los cuellos de gravedad alta de la última foto: sirven para avisar sólo de los nuevos. */
237 altas: string[]
238 cuando: number | null
239 /** La última foto leída, ya en líneas para el panel. */
240 lectura: Lectura
241}
242
243declare module 'claude-code' {
244 interface PluginState {
245 consumo: {
246 avance: Avance
247 resumen: Resumen | null
248 cobertura: Cobertura
249 sesion: Sesion
250 github: GitHub
251 control: Control
252 vivo: Vivo
253 }
254 }
255}
256