SLOPSHOPPER

consumo

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…

newpanecommandpromptprocesstimer
★ 1v2.3.0no licenseupdated 2026-10-08cherrera0001/claude-code-mod-consumo
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · consumo
│ ┃ Consumo ✕ › fix the failing auth test and add an audit log call │ ┃ Index vivo sin instalar: «node │ ┃ herramientas/instalar.mjs --raiz <este ⏺ Read(src/auth.ts) │ ┃ repositorio>» desde la carpeta del mod lo ⎿ Read 6 lines │ ┃ deja midiendo solo, sin sesión abierta. Sin ⏺ Update(src/auth.ts) │ ┃ él no hay foto: lo de abajo es sólo lo ⎿ Added 2 lines, removed 1 line │ ┃ declarado y la sesión ⏺ Bash(bun test) │ ┃ ⎿ 3 pass, 1 fail │ ┃ La orden del próximo despertar │ ┃ El control todavía no decidió, o este ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ repositorio no tiene │ ┃ .claude/orquestacion.json: «/consumo ✻ Worked for 42s · done 4:20 PM │ ┃ agentes» lo ejecuta │ ┃ Bucle: ninguno programado › /consumo │ ┃ ⎿ consumo: consumo: sin datos todavía (SyntaxError: JSON Parse err │ ┃ Quién está en qué · sin foto vigente, sólo │ ┃ lo declarado │ ┃ │ ┃ La sesión · 8767 h 09 min · medida 08:53 │ ┃ Presupuesto 200 USD ░░░░░░░░░░ quedan │ ┃ 199.58 USD (gastado el 0 % de 200 USD) │ ┃ Tanque de contexto █████░░░░░ queda libre │ ┃ el 51 % · ocupados 97k de 200k │ ┃ Ritmo (última hora) ▁▁▁▁▁▁▁▁▁▁▁▁ sin │ ┃ respuestas todavía │ ┃ Autonomía sesión recién abierta: ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Consumo
Index vivo sin instalar: «node herramientas/instalar.mjs --raiz <este repositorio>» desde la carpeta del mod lo deja midiendo solo, sin sesión abierta. Sin él no hay foto: lo de abajo es sólo lo declarado y la sesión La orden del próximo despertar El control todavía no decidió, o este repositorio no tiene .claude/orquestacion.json: «/consumo agentes» lo ejecuta Bucle: ninguno programado Quién está en qué · sin foto vigente, sólo lo declarado La sesión · 8767 h 09 min · medida 08:53 Presupuesto 200 USD ░░░░░░░░░░ quedan 199.58 USD (gastado el 0 % de 200 USD) Tanque de contexto █████░░░░░ queda libre el 51 % · ocupados 97k de 200k Ritmo (última hora) ▁▁▁▁▁▁▁▁▁▁▁▁ sin respuestas todavía Autonomía sesión recién abierta: aún sin gasto SyntaxError: JSON Parse error: Unexpected identifier "dev" Avance real · repositorio por detectar · medido 08:53 SyntaxError: JSON Parse error: Unexpected identifier "dev" sin medición todavía: «/consumo avance» la corre (tarda medio minuto) GitHub: este proyecto no tiene remoto de GitHub Pendiente en GitHub · 08:53 · PR abiertos 0 (0 en borrador) [ Actualizar ] [ GitHub ] [ Avance ] [ Control ]
README

consumo — mide, gestiona y controla el trabajo en Claude Code

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.

Integrarlo a tu repositorio en cinco pasos

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.

Si trabajas con más de un agente

Para que el control sepa quién hace qué, basta una convención y un fichero:

  • Cada agente trabaja en su propia rama con la forma 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.
  • Lo que 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.

Qué hace el control cuando despierta

En session.start y en cada despertar del bucle (/loop), antes de que nadie empiece, el hook:

  1. Lee las issues abiertas del repositorio (con el envoltorio de 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.
  2. Clasifica cada issue en una sola clase: Cierre, Construcción, Interina, Decisión o Épica. Una Decisión (P-*, decision-humana) no se asigna: queda «espera al dueño». Una épica no lleva modelo.
  3. Pesa lo que falta, no la issue entera. Primero lee la etiqueta de la issue, si la trae: 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.
  4. Asigna, reasigna o se detiene, y lo escribe en .claude/orquestacion.json:
PuntosPesoEsfuerzoQuién lo hace
0XSbajoesta sesión
1Smedioesta sesión
2Maltoesta sesión, o Agy si esta sesión está desplegando
3Lmuy altoClaude construye; Agy o Codex, el que esté libre, revisa en sólo lectura
4–5XLmáximo, con plan antesno 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.

  1. Le da al despertar sólo la orden. Cuando el bucle despierta (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:

  • Un trabajador, una issue. Una asignación vigente no se reasigna.
  • La que espera al dueño no ocupa a nadie. Una asignación cuya issue tiene 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.
  • Un trabajador externo que lleva más de 30 minutos sin mover su rama agente/número y sin nada a medias pasa a «por retomar», y el siguiente despertar se la da al siguiente que esté libre.
  • «A medias» no es «parado». Si en ese árbol hay ficheros sin confirmar, o entradas en el stash de esa rama, la asignación sigue vigente con el motivo «tiene trabajo a medias: preguntar al dueño antes de reasignar». El control no reasigna por reloj un trabajo que existe y no está en ningún commit.
  • Quien está en fuera no recibe nada, y el motivo queda a la vista.
  • Agy trabaja en <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.
  • Empujar main, migrar producción y desplegar el API no se delegan.
  • Si el candado de suites que el repositorio declara en herramientas.candado (por ejemplo .suite.lock) está tomado, el control lo dice y no lanza ninguna suite pesada.
  • Si GitHub no responde, lo dice y enruta sólo la cola escrita; no pesa a ciegas ni da nada por cerrado.

El control escucha: la orden sale de las señales

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ñalOrden
1Credencial rechazadaParar 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
2Remoto movido bajo un árbol de esta sesión con trabajoAntes de verificar o empujar: traer el remoto y volver a medir
3Número de migración repetido o sin reserva que afecta a una issue de esta sesiónReservar o renumerar antes de seguir; no encargar otra migración hasta que cuadre
4Producción con código distinto del de la rama principalDesplegar, en orden: migraciones, API, web
5Pieza terminada sin integrar, de una asignación vigente, que espera más que el P95 de los huecos entre empujesIntegrar esa rama antes de construir nada nuevo
6Trabajador externo con trabajo a medias y sin moversePreguntar al dueño por él; no reasignar
7NingunaLa 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>.

Uso

ComandoQué hace
/consumoPinta el panel y devuelve el resumen de la sesión
/consumo avanceVuelve a medir issues cerradas y parte de la factura (tarda medio minuto), refresca el index y dice dónde quedó
/consumo agentesEjecuta 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 githubRelee issues y PR abiertos
/consumo coberturaCorre pytest --cov (sólo donde hay pytest)

Configuración

Se pide al instalar y se cambia en /plugin:

OpciónPara quéPor defecto
facturaMensualUsdLo que pagas al mes; se reparte entre los proyectos de la máquina238 (un ejemplo: pon la tuya)
presupuestoUsdPresupuesto de la sesión, para la barra y la autonomía200
repodueño/nombre del repositorio de issuesel remoto origin
cuentaGitHubCuenta de gh con la que se consultala dueña del repositorio

Los dos ficheros del control

En la raíz del repositorio de trabajo hay dos ficheros, y sólo uno se versiona:

FicheroQuién lo escribeSe versionaQué lleva
.claude/orquestacion.jsontúsícola, fuera, declarado, no_tocar, produccion, herramientas, migraciones
.claude/orquestacion.local.jsonel controlnoasignaciones, 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:

ClaveQué es
colaEl orden en que se toman las issues
fueraTrabajador → motivo
declaradoPor 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_tocarArchivos que un trabajador externo no puede tocar
produccionsalud: 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
herramientasLo 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)
migracionesLa 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 vivo

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.

Instalarlo y quitarlo

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:

  • copia 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);
  • pone dos disparadores:
  • hooks de git (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;
  • una tarea programada cada N minutos: en Windows con 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;
  • imprime la línea que hay que pegar en el encargo de un agente que no ejecuta el mod: «Antes de empezar, de empujar y de numerar una migración, lee <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).

Qué mide

Por cada repositorio, en unos segundos (51 árboles, 3 s):

  • Árboles y ramas: de cada worktree, su rama, su último commit, cuántos commits va por delante y por detrás de 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.
  • Quién: por la convención de rama agente/número; el árbol principal es «sesión principal».
  • Dónde espera el trabajo: commits escritos y sin llegar a 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.
  • Remoto movido: 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.
  • Migraciones: el prefijo numérico de cada fichero de la carpeta de migraciones en 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.
  • Reservas de migración, si el repositorio las declara:
  "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"
  }
  • Candado de suite, control (quién tiene cada issue según lo declarado, su paso sin hacer, quién está fuera) e incidentes de coordinación (un fichero de una línea por incidente, separado por tabuladores: fecha hora causa coste_min deteccion recurso issue frase; la frase no se lee).
  • GitHub, sólo si el repositorio declara credencial: si está vigente o rechazada, issues abiertas, cerradas hoy, cuántas abiertas no pueden avanzar solas (espera declarada, etiqueta de bloqueo, peso XL sin partir) y el estado de despliegue del último commit de main. Sin credencial, todo eso es NO MEDIDO.
  • Producción, si el repositorio declara 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.

El cálculo diario

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:

  • la tabla semana a semana (creadas, hechas, descartadas, neto), con los cierres en bloque aparte: cinco o más cierres con diez minutos o menos entre uno y otro son una anotación tardía, no trabajo de ese rato;
  • el tiempo de entrega de las issues construidas (mediana, P85 y P95, con su n, y cuántas se cerraron a menos de una hora de crearse). Cada resumen tiene su n mínimo —10, 20 y 40—; por debajo se dice «insuficiente»;
  • las issues hechas sin ningún commit que las nombre;
  • los commits sin integrar al cierre de cada día;
  • la proyección de la cola, sólo con 8 semanas completas o más y sólo si no cambia de rango al quitar una semana. Con menos, NO MEDIDO y la razón. La simulación usa una semilla fija.

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.

Los cuellos de botella

Van arriba del todo, en index.json (cuellos, con gravedad, que, desde y repositorio) y en la página.

GravedadCuelloCuándo salta
altacredencial rechazadaGitHub responde 401 a la credencial declarada
altamigraciónel 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
altasiguiente_libre que no es libreel declarado es menor o igual que un número ya usado o reservado
altaremoto movidoel remoto movió main y hay un árbol con trabajo debajo
altahueco de integraciónmás tiempo sin empujes que el P95 de los huecos, con commits de hoy esperando
altasin despliegueel último commit de main lleva más de 10 min sin despliegue, o el despliegue falló
altaproducciónresponde 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»
mediaproducción no comparableel 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

Source 5 files
hooks/register.tsx 1786 lines
1import { 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 lines
1// 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}
619
hooks/panel.ts 175 lines
1// 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}
175
hooks/indice.ts 214 lines
1// 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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;')
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}
214
types/index.d.ts 256 lines
1export 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