Picks the model and effort of every subagent from its task type; with hot mode on, raises them when the 5-hour window resets with usage to spare

English documentation: README · how it works · configuration. Este README es la referencia técnica del plugin, en castellano.
Mod de Claude Code que elige el modelo y el esfuerzo de cada subagente según el tipo de tarea. Con el modo hot prendido (/router hot on, viene apagado), además los sube cuando sobra ventana de 5 horas. Nunca frena el uso.
claude --plugin-dir <repo>/routerclaude plugin marketplace add CiroVilmer/jev-agent-router y después claude plugin install agent-router@jev-agent-router| Comando | Qué hace |
|---|---|
/router | Abre el panel: pestañas En curso, Métricas vs tu base e Historial (teclas 1, 2 y 3) |
/router off / /router on | Apaga o prende el router en esta sesión |
/router allow sonnet,opus | Solo usa esos modelos en esta sesión |
/router set review opus·max | Fija un tipo por encima de la política. review fija las dos tallas; review-large y review-small, cada una por separado |
/router reset | Borra los overrides de la sesión |
/router hot on · /router hot off | Prende o apaga el modo hot (se guarda para las próximas sesiones; apagado por defecto). /router hot dice cómo está |
/router bench | Corre el banco del clasificador (ver abajo) |
/router history [días] | Exporta las decisiones de los últimos días (7 si no se dice) a ~/.claude/router-historial.md |
[pin] en la description de un subagente | Respeta el modelo que pidió Claude |
En /config se eligen el modelo·esfuerzo base de cada tipo de tarea (modeloReviewLarge, modeloReviewSmall, modeloFix…), los umbrales de la política (🔥 y ⬆️), los clasificadores (ver abajo), avisarAClaude y ensenarConvencion.
El router suma una sección (router:pin) al system prompt. Le dice que el modelo y el esfuerzo de cada subagente los elige el router, que no gaste razonamiento eligiendo model y que use ese tiempo en descomponer el trabajo: lanzar en paralelo las tareas independientes y serializar las que tocan los mismos archivos, comparten estado o dependen de otra.
La convención de nombres va en la descripción de la tool Agent, donde Claude la lee justo cuando escribe la description (un hook tool.describe): empezar con el verbo del tipo (Implement, Review, Re-review, Fix, Explore, Research, Write), nombrar la unidad como Task N, y [pin] si de verdad necesita un modelo exacto. El texto está en CONVENCION_AGENT (router/src/convencion.ts) y la sección del system prompt en LINEA_ROUTER (router/src/overrides.ts). /router off no saca el texto de la tool: el host lo cachea por sesión.
ensenarConvencion (/config, prendido por defecto): cuando ninguna regla reconoce la description de un subagente y lo clasificó otro camino, Claude ve una línea en el resultado de Agent, por ejemplo router: no reconocí el tipo de "v2: kubernetes" y lo clasifiqué como build-small. Empezá la description con el verbo del tipo (...) y usá Task N.. Si avisarAClaude también está prendido salen las dos líneas, primero la de avisarAClaude.
avisarAClaude (/config, apagado por defecto): cuando el router reasigna un subagente, Claude ve una línea corta en el resultado de la tool Agent, por ejemplo (router: corre en opus·medium — regla review). Va como context del resultado (un hook tool.call sobre Agent, unido al spawn por el tool_use_id): lo lee el modelo y no se muestra en el transcript. Sin reasignación no agrega nada.
Cuando las reglas no alcanzan para saber el tipo (un build sin talla clara, o una description sin regla) y, con jev, también para las reviews por description, decide un clasificador. Se configura en /config:
| Campo | Default | Qué hace |
|---|---|---|
clasificador | reglas | Quién decide: reglas (el respaldo, sin modelo), haiku o jev |
sombra | off | Corre otro clasificador en paralelo, sin afectar la decisión: off, haiku o jev |
jevApiKey | (vacío) | La key de Jev (TypeSafe). Se guarda como dato sensible |
jevUmbral | 0.7 | En la talla de los builds, con menos confianza que esto decide el respaldo. También decide en las reviews por description cuando el clasificador es Jev: con menos confianza queda la regla. Y se aplica a la talla de las reviews: con menos confianza queda review-small. Sin regla, decide Jev igual y el motivo lo marca (sin umbral). Se acota a [0, 1]. Calibrado en el banco del 2026-10-07 para la talla y, para las reviews, en bench/corridas/2026-10-09-1339-jev-todos-analisis.md (0.7 simulado el 2026-10-09) |
topeMs | 1000 | Si el clasificador tarda más, decide el respaldo. Se acota a [50, 8000] |
La key de Jev: /plugin configure → jevApiKey, o la variable de entorno JEV_API_KEY. Sin key, clasificador: jev usa Haiku y sombra: jev queda desactivada; las dos cosas se avisan con un toast.
Reviews: cuando una regla de description (no de subagentType) dice review-final, review o re-review, y clasificador es jev (con key), el router consulta a Jev con las 9 etiquetas. Con reglas o haiku como principal, aunque haya sombra, no consulta a Jev por la familia y queda la regla; la talla de la review sí se consulta con haiku o con sombra. Si Jev está seguro (confianza ≥ jevUmbral), decide él, aunque cambie el tipo: motivo jev → review (0.98). Si duda, queda la regla: jev dudó (0.55): queda la regla. Si el clasificador cayó al respaldo (tope o error), queda la regla y el motivo lo dice: respaldo (tardó más de 1000 ms): queda la regla.
Modo sombra: con sombra en haiku o jev, cada vez que hace falta un clasificador corre también el de sombra, en segundo plano y con un límite de 60 s (no topeMs), y guarda las dos respuestas en el store del plugin (las últimas 500). Los números se ven en /router → pestaña Métricas vs tu base → CLASIFICADORES, por par principal→sombra: cuántas veces coinciden (solo entre las respuestas propias de los dos), p50 y p95 de cada uno, cuántas cayeron al respaldo y cuántas respuestas de la sombra pasaron topeMs. Sirve para juntar números con tráfico real antes de cambiar clasificador.
Banco: /router bench [--max N] [--solo haiku|jev] corre los clasificadores sobre los subagentes reales de bench/casos.json y node bench/comparar.mjs los compara contra una referencia y contra el respaldo. Cómo usarlo y cómo leer la tabla: bench/README.md.
/router → pestaña Métricas vs tu base compara tres columnas:
| Columna | De dónde sale |
|---|---|
| sesión | Los subagentes de esta sesión, en memoria |
| 7 días | El historial guardado de todas las sesiones, en el rango (ahora − 7 días, ahora] |
| base | La línea base de antes del router (router/src/lineaBase.ts) |
Qué mide cada fila:
review (review-large y review-small), review-final y re-review, la proporción que corrió en Opus. Cuenta el modelo con el que corrió de verdad: el que eligió el router o, si el router no lo tocó ([pin], /router off, un error), el model pedido o el del padre si hereda. Un subagente sin tocar toma el tipo de las reglas, como la base.re-review por tarea, sobre las tareas con al menos un review. La tarea sale de la description (Task 6, Tarea 6, T6) y, desde la fase 5, también de <prefijo>: <nombre> (v2: kubernetes y verif: kubernetes son la misma tarea). La línea base se generó sin ese agrupamiento y no se regenera, así que la comparación de min/tarea no es exactamente igual. En 7 días, la misma tarea de sesiones distintas cuenta por separado.Una tarea entra o queda afuera de los 7 días entera, según el fin de su último subagente: no se parte en el borde.
La línea de ventanas: 5 h sin usar al reset (7 días): 25% en 2 ventanas · vs semana anterior 50% (no hay base previa al router) · 1 sin cobertura.
100 − el % más alto que se vio.(ahora − 14 días, ahora − 7 días], y no contra la base. Los dos rangos tienen el borde de abajo abierto, así una ventana no cae en los dos.Dónde se guarda: en el store del plugin, un JSON propio dentro de la carpeta de configuración de Claude Code.
| Clave | Qué guarda | Tope |
|---|---|---|
historial:<sessionId> | Un registro por subagente terminado de esa sesión (unos 450 B con la decisión). Cada sesión escribe solo la suya | 3000 registros por sesión. Al escribir se borran las sesiones cuyo último registro tiene 14 días o más y, si entre todas pasan de 3000, las más viejas |
ventanas | Una entrada por ventana de 5 h: el % más alto visto y cuándo se vio por primera y última vez | Las 300 más recientes |
sombra | Las muestras del modo sombra | Las últimas 500 |
La clave vieja historial (de antes de guardar por sesión) se lee como una sesión más y, en la primera escritura, se mueve a historial:legado y se borra.
La base es una foto congelada: la genera node bench/linea-base.mjs desde los transcripts de antes del router y la escribe en router/src/lineaBase.ts. Claude Code poda los transcripts a los ~30 días, así que regenerarla más adelante da otra base: hacelo solo a propósito. Cómo se calcula cada número: el encabezado de lineaBase.ts y bench/README.md.
Sirve para revisar que el router decida bien. Cada registro del historial guarda, además del modelo y los tiempos, la decisión: la description (hasta 100 caracteres), lo elegido (opus·xhigh, o lo pedido si el router no lo tocó), los motivos (regla review, jev → build-large (0.83), talla por alcance (feature), talla del build de Tarea 3 (build-large), jev → review-large (0.91), respaldo → review-small, jev (sin umbral) → review (0.55), jev → review (0.98), jev dudó (0.55): queda la regla, política +2, [pin]: …) y el modo de la política al lanzarlo (fuego, sobra o normal). Los registros de antes no tienen esos campos: se muestran con lo que hay.
Pestaña Historial (/router, tecla 3): arriba, cuántas decisiones hubo en 7 días, cuántas reasignadas y por qué modelo corrieron; abajo, las últimas 25 (las de esta sesión, también las que siguen corriendo, y las guardadas de todas las sesiones), cada una con hora, tarea, description, pedido → elegido y el modo, y los motivos debajo, atenuados. Con menos de 70 columnas no se muestran los motivos. El resumen cuenta por el momento del spawn.
/router history [días] escribe <home>/.claude/router-historial.md (el home sale de USERPROFILE o HOME), lo sobrescribe en cada export y responde con la ruta. Tiene un encabezado con la fecha, el rango y el resumen (decisiones, reasignadas, por tipo → modelo, por modo) y una sección por sesión (id corto y fecha) con una tabla por tarea: hora, tipo, description, pedido → elegido, modo y motivos, más el ciclo de la tarea y sus rondas de re-review. El rango es el de la columna de 7 días: una tarea entra o queda afuera entera.
Una review no es un solo escalón: son dos tallas, review-large (opus·xhigh) y review-small (opus·medium). El router decide la talla en este orden, y manda el primero que aplica:
review-large.build-large → review-large, build-small → review-small).bloque/block, feature, fase/phase, integración/integration, rama/branch o merge la hacen review-large.review-small.Los motivos en el historial: talla por alcance (…), talla del build de Tarea 3 (build-large), jev → review-large (0.91) y respaldo → review-small.
En /config: modeloReviewLarge (por defecto opus·xhigh) y modeloReviewSmall (por defecto opus·medium). modeloReview queda como legado: aplica a las dos tallas si no se eligieron por separado. /router set review X fija las dos; /router set review-large X y /router set review-small X, cada una por separado.
El banco bench/review-talla.mjs mide esta política: da 96% sobre 225 reviews con Jev y también con el respaldo solo (sin Jev): casi todo lo decide el build de la misma tarea. 192 de las 225 aciertan por construcción (la etiqueta sale del mismo build); en las 33 manuales la política acierta 24 (72,7%). Hay 33 etiquetas manuales que todavía tiene que validar el usuario (ver bench/README.md).
review-final y review-large: opus·xhigh · review-small, re-review y build-large: opus·medium · build-small y research: sonnet·medium · fix: sonnet·high · write: haiku·high · explore: haiku·medium (techo haiku·high). La escalera tiene tres escalones de Haiku 5.5: haiku·low, haiku·medium y haiku·high.
Spec: docs/superpowers/specs/2026-10-06-router-de-subagentes-design.md y docs/superpowers/specs/2026-10-09-router-fase-5-design.md
hooks/register.tsx 632 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, PluginOptions, Register } from 'claude-code'
3import type { LecturaUso, Pestana, Subagente } from '../types'
4import type { Caso, ResultadoBanco } from '../src/banco'
5import { casosDeProduccion, colaEnOrden, conLimite, correrBanco, nombreCorrida } from '../src/banco'
6import type { Clasificador } from '../src/clasificadores'
7import { limitesDe, respaldo } from '../src/clasificadores'
8import { crearHaiku } from '../src/clasificadores/haiku'
9import { crearJev, JEV_URL } from '../src/clasificadores/jev'
10import type { Muestra } from '../src/clasificadores/sombra'
11import { agregarMuestra, conSombra, estadisticasSombra, muestrasDe } from '../src/clasificadores/sombra'
12import { conTope } from '../src/clasificadores/tope'
13import { avisoConvencion, CONVENCION_AGENT } from '../src/convencion'
14import type { Decision } from '../src/decidir'
15import { decidir, motivoError } from '../src/decidir'
16import type { HistorialSesion } from '../src/historial'
17import {
18 agregarRegistro,
19 CLAVE_LEGADO,
20 CLAVE_VIEJA,
21 claveHistorial,
22 clavesAPodar,
23 esClaveHistorial,
24 historialDe,
25 MAX_HISTORIAL,
26 metricasHistorial,
27 registroDe,
28 resumenDecisiones,
29 ultimasDecisiones,
30 ultimosDias,
31 unirRegistros,
32} from '../src/historial'
33import { historialMarkdown } from '../src/historialMarkdown'
34import { aplicarComando, describirComando, LINEA_ROUTER, OVERRIDES_INICIAL, parsearComando } from '../src/overrides'
35import { lecturaDe, politicaSegunHot, umbralesDe } from '../src/politica'
36import { tallaBuildDe, tareaDe } from '../src/registro'
37import { clasificarPorReglas } from '../src/reglas'
38import { formatEscalon, tablaDe } from '../src/taxonomia'
39import { textoLinea } from '../src/ui/linea'
40import { DECISIONES_EN_PANEL, vistaPanel } from '../src/ui/panel'
41import { observarVentana, resumenVentanas, ventanasDe } from '../src/ventanas'
42
43export const PANE = 'router'
44
45// Los atoms viven en este archivo y no en un módulo aparte: el cargador de hooks lista lo que
46// el módulo lee y escribe, y solo acepta en read/update un atom declarado en un const de este
47// mismo archivo (un atom importado hace que el módulo no cargue).
48export const subagentesAtom = atom({ plugin: 'agent-router', key: 'subagentes' } as const, [] as Subagente[])
49export const overridesAtom = atom({ plugin: 'agent-router', key: 'overrides' } as const, OVERRIDES_INICIAL)
50export const lecturaAtom = atom({ plugin: 'agent-router', key: 'lectura' } as const, null as LecturaUso | null)
51export const pestanaAtom = atom({ plugin: 'agent-router', key: 'pestana' } as const, 'curso' as Pestana)
52
53// El cargador de hooks solo deja pasar $ a funciones declaradas en el nivel superior del
54// archivo: por eso refrescarLinea vive acá afuera.
55export async function refrescarLinea($: EngineInterface): Promise<void> {
56 // La última decisión del router: los subagentes se agregan en orden de spawn.
57 $.ui.status(textoLinea((await read($, subagentesAtom)).at(-1)))
58}
59
60// Cada aviso de que falta la key de Jev sale una sola vez por carga del módulo.
61const avisosSinKey = new Set<string>()
62
63/** Límite de la sombra: generoso, como el del banco. Corre en segundo plano y no frena la decisión. */
64export const LIMITE_SOMBRA_MS = 60_000
65
66/**
67 * El tope de tiempo con una espera propia por llamada: cuando el clasificador gana la carrera,
68 * la espera se corta y no queda viva gastando el presupuesto del hook. Si llega `signal` (el del
69 * hook), abandonar el dispatch también la corta, y entonces decide el respaldo.
70 */
71export function conTopeVivo($: EngineInterface, c: Clasificador, ms: number, signal?: AbortSignal): Clasificador {
72 return conEsperaCortable($, dormir => conTope(c, ms, dormir), signal)
73}
74
75/**
76 * Arma, por llamada, un clasificador con una espera de `$.clock.sleep` que se corta al terminar
77 * la llamada (o al abortar `signal`). La usan el tope de producción y el límite del banco.
78 */
79export function conEsperaCortable(
80 $: EngineInterface,
81 armar: (dormir: (ms: number) => Promise<void>) => Clasificador,
82 signal?: AbortSignal,
83): Clasificador {
84 return async p => {
85 // Si el hook ya se abandonó, no se arma la espera ni se hace el pedido: decide el respaldo.
86 if (signal?.aborted) return { ...(await respaldo(p)), detalle: 'abortado' }
87 const corte = new AbortController()
88 const cortar = () => corte.abort()
89 signal?.addEventListener('abort', cortar)
90 try {
91 return await armar(espera => $.clock.sleep(espera, { signal: corte.signal }))(p)
92 } finally {
93 signal?.removeEventListener('abort', cortar)
94 corte.abort()
95 }
96 }
97}
98
99/**
100 * El POST a Jev. Si el pedido falla, el mensaje sale sin la key (por si el error la repitiera):
101 * termina en el `detalle` del respaldo, que el panel muestra.
102 */
103export async function postJev($: EngineInterface, key: string, cuerpo: string) {
104 try {
105 return await $.http.fetch(JEV_URL, {
106 method: 'POST',
107 headers: { Authorization: `Bearer ${key}`, 'Content-Type': 'application/json' },
108 body: cuerpo,
109 })
110 } catch (error) {
111 const mensaje = error instanceof Error ? error.message : String(error)
112 throw new Error(`jev: ${mensaje.split(key).join('***')}`)
113 }
114}
115
116/** Haiku por `$.model.classify`, sin tope: el tope lo pone quien lo usa. */
117export function armarHaiku($: EngineInterface): Clasificador {
118 return crearHaiku((texto, etiquetas) => $.model.classify(texto, etiquetas, { model: 'haiku' }), () => $.clock.now())
119}
120
121/** La key de Jev, de /config o de JEV_API_KEY; null si no hay. Nunca se loguea ni se guarda. */
122export async function keyJev($: EngineInterface, options: PluginOptions): Promise<string | null> {
123 return (typeof options.jevApiKey === 'string' && options.jevApiKey) || (await $.env.get('JEV_API_KEY')) || null
124}
125
126/**
127 * Jev con una key ya buscada, sin tope. La key queda capturada en el closure y solo sale en el
128 * header del POST (postJev la tapa en los errores).
129 */
130export function armarJevConKey($: EngineInterface, key: string, umbral: number): Clasificador {
131 return crearJev(cuerpo => postJev($, key, cuerpo), () => $.clock.now(), umbral)
132}
133
134/** Jev con la key de /config o de JEV_API_KEY, sin tope; null si no hay key. */
135export async function armarJev($: EngineInterface, options: PluginOptions, umbral: number): Promise<Clasificador | null> {
136 const key = await keyJev($, options)
137 return key ? armarJevConKey($, key, umbral) : null
138}
139
140export function avisarSinKey($: EngineInterface, consecuencia: string): void {
141 const texto = `Router: falta la key de Jev (/config o JEV_API_KEY); ${consecuencia}`
142 if (avisosSinKey.has(texto)) return
143 avisosSinKey.add(texto)
144 $.ui.toast(texto)
145}
146
147// Arma el clasificador según /config. $ entra acá (función de nivel superior) y sale capturado
148// en closures: decidir recibe un Clasificador y no conoce $.
149export async function armarClasificador(
150 $: EngineInterface,
151 options: PluginOptions,
152 signal?: AbortSignal,
153): Promise<{ clasificar: Clasificador; jevDecideReviews: boolean }> {
154 const principalReglas = options.clasificador !== 'haiku' && options.clasificador !== 'jev'
155 // Con reglas y sin sombra no hace falta nada más: decide el respaldo, sin modelo.
156 if (principalReglas && options.sombra !== 'haiku' && options.sombra !== 'jev') {
157 return { clasificar: respaldo, jevDecideReviews: false }
158 }
159 const { topeMs, jevUmbral } = limitesDe(options)
160 const ahora = () => $.clock.now()
161
162 // La key se busca solo si alguien va a usar Jev.
163 const key = options.clasificador === 'jev' || options.sombra === 'jev' ? await keyJev($, options) : null
164
165 // El principal: reglas (el respaldo, sin tope: no espera a nadie, y así se juntan muestras de
166 // un clasificador antes de pasarse a él), haiku, o jev con su umbral. Jev sin key cae a haiku.
167 let principal: { nombre: string; c: Clasificador }
168 if (principalReglas) principal = { nombre: 'reglas', c: respaldo }
169 else if (options.clasificador === 'haiku') principal = { nombre: 'haiku', c: armarHaiku($) }
170 else if (key) principal = { nombre: 'jev', c: armarJevConKey($, key, jevUmbral) }
171 else {
172 avisarSinKey($, 'uso Haiku')
173 principal = { nombre: 'haiku', c: armarHaiku($) }
174 }
175 // Las reviews por description consultan a Jev solo si Jev es el principal y hay key.
176 const jevDecideReviews = principal.nombre === 'jev'
177 const decide = principalReglas ? respaldo : conTopeVivo($, principal.c, topeMs, signal)
178
179 // La sombra: Jev con umbral 0, para guardar su elección y su confianza reales. Jev sin key no
180 // se reemplaza por otro: la sombra queda desactivada.
181 let sombra: { nombre: string; c: Clasificador } | null = null
182 if (options.sombra === 'haiku') sombra = { nombre: 'haiku', c: armarHaiku($) }
183 else if (options.sombra === 'jev' && key) sombra = { nombre: 'jev', c: armarJevConKey($, key, 0) }
184 else if (options.sombra === 'jev' && options.clasificador !== 'jev') avisarSinKey($, 'sombra desactivada')
185 if (!sombra || sombra.nombre === principal.nombre) return { clasificar: decide, jevDecideReviews }
186
187 // La sombra no lleva el signal del hook ni el tope de producción: sigue en segundo plano después
188 // de que el spawn sale, con un límite generoso, y guarda sus ms reales y si pasó topeMs.
189 const clasificar = conSombra(decide, conTopeVivo($, sombra.c, LIMITE_SOMBRA_MS), m => guardarMuestra($, m), ahora, {
190 principal: principal.nombre,
191 sombra: sombra.nombre,
192 }, topeMs)
193 return { clasificar, jevDecideReviews }
194}
195
196// Una cola de escrituras por clave del store: dos escrituras que llegan juntas (dos sombras, dos
197// subagentes que terminan a la vez) no se pisan el get → set.
198const colas = new Map<string, Promise<void>>()
199
200/**
201 * Encola `get → f → set` sobre `clave`. Registrar es secundario: una falla (del store o de `f`)
202 * se traga y no corta la cola. Devuelve la promesa de esta escritura, que nunca rechaza.
203 */
204export function escribirEnCola(
205 $: EngineInterface,
206 clave: string,
207 f: (actual: unknown) => unknown | Promise<unknown>,
208): Promise<void> {
209 const escritura = (colas.get(clave) ?? Promise.resolve())
210 .then(async () => {
211 const nuevo = await f(await $.store.get(clave))
212 await $.store.set(clave, nuevo)
213 })
214 .catch(() => {})
215 colas.set(clave, escritura)
216 return escritura
217}
218
219export function guardarMuestra($: EngineInterface, m: Muestra): Promise<void> {
220 return escribirEnCola($, 'sombra', actual => agregarMuestra(muestrasDe(actual), m))
221}
222
223/**
224 * Suma al historial el subagente que acaba de terminar, en la clave de esta sesión
225 * (`historial:<sessionId>`), y después poda. Nunca rechaza: registrar es secundario.
226 */
227export async function guardarRegistro($: EngineInterface, sub: Subagente): Promise<void> {
228 const sesion = await $.session.id().catch(() => null)
229 if (sesion === null) return
230 const r = registroDe(sub, sesion)
231 if (!r) return
232 const clave = claveHistorial(sesion)
233 await escribirEnCola($, clave, actual => agregarRegistro(historialDe(actual), r))
234 await podarHistorial($, clave)
235}
236
237/** Lee el historial de todas las sesiones (y la clave vieja, si sigue). Una clave que falla queda con `registros: null`. */
238export async function leerHistoriales($: EngineInterface): Promise<HistorialSesion[]> {
239 const claves = (await $.store.keys()).filter(esClaveHistorial)
240 return Promise.all(
241 claves.map(async clave => ({ clave, registros: await $.store.get(clave).then(historialDe, () => null) })),
242 )
243}
244
245/**
246 * Migra la clave vieja `historial`: la suma a `historial:legado`, que queda como una sesión más, y
247 * la borra. Si la escritura falla, no borra: se reintenta en la próxima poda.
248 */
249export async function migrarHistorialViejo($: EngineInterface): Promise<void> {
250 const viejo = historialDe(await $.store.get(CLAVE_VIEJA))
251 if (viejo.length) {
252 const legado = historialDe(await $.store.get(CLAVE_LEGADO))
253 await $.store.set(CLAVE_LEGADO, [...legado, ...viejo].slice(-MAX_HISTORIAL))
254 }
255 await $.store.delete(CLAVE_VIEJA)
256}
257
258// Las podas del proceso van de a una: dos que terminan juntas no leen y borran a la vez.
259let colaPoda: Promise<void> = Promise.resolve()
260
261/**
262 * Poda el historial al escribir: migra la clave vieja y borra las sesiones de más de 14 días y,
263 * si el total pasa de MAX_HISTORIAL, las más viejas. Nunca toca `actual` y nunca rechaza.
264 */
265export function podarHistorial($: EngineInterface, actual: string): Promise<void> {
266 colaPoda = colaPoda
267 .then(async () => {
268 if ((await $.store.keys()).includes(CLAVE_VIEJA)) await migrarHistorialViejo($).catch(() => {})
269 const [sesiones, ahora] = await Promise.all([leerHistoriales($), $.clock.now()])
270 await Promise.all(clavesAPodar(sesiones, ahora, actual).map(clave => $.store.delete(clave).catch(() => {})))
271 })
272 .catch(() => {})
273 return colaPoda
274}
275
276/**
277 * Suma la lectura de 5 h a la ventana que le toca. La hora se lee dentro de la cola: dos lecturas
278 * seguidas quedan en orden y `ultimaVista` nunca retrocede.
279 */
280export function guardarVentana($: EngineInterface, lectura: LecturaUso): Promise<void> {
281 return escribirEnCola($, 'ventanas', async actual => observarVentana(ventanasDe(actual), lectura, await $.clock.now()))
282}
283
284/** Adónde va el export de `/router history`, dentro del home. */
285export const ARCHIVO_HISTORIAL = ['.claude', 'router-historial.md'] as const
286
287/** `<home>/.claude/router-historial.md`, con el separador que ya usa el home (`\` en Windows). */
288export function rutaHistorial(home: string): string {
289 const sep = home.includes('\\') ? '\\' : '/'
290 return [home.replace(/[\\/]+$/, ''), ...ARCHIVO_HISTORIAL].join(sep)
291}
292
293/**
294 * /router history [days]: arma el Markdown con el historial guardado de todas las sesiones más lo
295 * de esta que todavía no llegó al store, lo escribe en `<home>/.claude/router-historial.md`
296 * (sobrescribe) y devuelve la línea de respuesta. Rechaza si no hay home o si la escritura falla.
297 */
298export async function exportarHistorial($: EngineInterface, dias: number): Promise<string> {
299 const home = (await $.env.get('USERPROFILE')) || (await $.env.get('HOME'))
300 if (!home) throw new Error('no encuentro el home: USERPROFILE ni HOME')
301 const [guardado, subs, sesion, ahora] = await Promise.all([
302 leerHistoriales($).catch(() => [] as HistorialSesion[]),
303 read($, subagentesAtom),
304 $.session.id().catch(() => null),
305 $.clock.now(),
306 ])
307 const enMemoria = sesion === null ? [] : subs.flatMap(s => registroDe(s, sesion) ?? [])
308 const registros = unirRegistros(guardado.flatMap(s => s.registros ?? []), enMemoria)
309 const ruta = rutaHistorial(home)
310 await $.fs.write(ruta, historialMarkdown(registros, ahora, dias))
311 const n = ultimosDias(registros, ahora, dias).length
312 const rango = dias === 1 ? 'del último día' : `de los últimos ${dias} días`
313 return `Router: ${n} ${n === 1 ? 'decisión' : 'decisiones'} ${rango} → ${ruta}`
314}
315
316/** La línea que ve Claude en el resultado de Agent cuando el router reasignó el subagente. */
317export function avisoReasignado(s: Subagente): string {
318 const motivos = s.motivos.join(' · ')
319 return `(router: corre en ${s.elegido}${motivos ? ` — ${motivos}` : ''})`
320}
321
322/** Límite por llamada del banco: generoso, solo para que una llamada colgada no lo frene. */
323export const LIMITE_BANCO_MS = 60_000
324
325/**
326 * /router bench: corre Haiku y Jev sobre los casos de producción de bench/casos.json, por el
327 * mismo camino que producción pero sin tope ni sombra, y con umbral 0 para que Jev no se
328 * descarte por duda (la confianza queda en la corrida). Guarda bench/corridas/<fecha>-<hora>-banco.json
329 * cada 25 resultados y al final; devuelve el resumen de una línea.
330 */
331export async function correrBancoEnSesion(
332 $: EngineInterface,
333 options: PluginOptions,
334 comando: { max: number | null; solo: 'haiku' | 'jev' | null },
335): Promise<string> {
336 const cwd = await $.session.cwd()
337 const { casos } = JSON.parse(String(await $.fs.read(`${cwd}/bench/casos.json`))) as { casos: Caso[] }
338 const elegidos = casosDeProduccion(casos).slice(0, comando.max ?? undefined)
339
340 const clasificadores: Partial<Record<'haiku' | 'jev', Clasificador>> = {}
341 let aviso = ''
342 if (comando.solo !== 'jev') clasificadores.haiku = armarHaiku($)
343 if (comando.solo !== 'haiku') {
344 const jev = await armarJev($, options, 0)
345 if (jev) {
346 clasificadores.jev = jev
347 } else {
348 clasificadores.haiku ??= armarHaiku($)
349 aviso = ' · sin key de Jev (/config o JEV_API_KEY): corrió solo Haiku'
350 }
351 }
352
353 // Sin tope de producción, pero con un límite generoso por llamada: una colgada no frena el banco.
354 const conLimites: Partial<Record<'haiku' | 'jev', Clasificador>> = {}
355 for (const [fuente, c] of Object.entries(clasificadores) as ['haiku' | 'jev', Clasificador][]) {
356 conLimites[fuente] = conEsperaCortable($, dormir => conLimite(c, LIMITE_BANCO_MS, dormir))
357 }
358
359 const ahora = new Date(await $.clock.now())
360 // `fecha` va en ISO UTC; el nombre del archivo, en hora local.
361 const fecha = ahora.toISOString()
362 const relativa = nombreCorrida(ahora)
363 const nombres = Object.keys(clasificadores)
364 // Los guardados pasan por una cola: uno lento no pisa a uno posterior.
365 const enCola = colaEnOrden()
366 const guardar = (resultados: readonly ResultadoBanco[]) =>
367 enCola(() => $.fs.write(`${cwd}/${relativa}`, JSON.stringify({ fecha, clasificadores: nombres, resultados }, null, 1) + '\n'))
368
369 const resultados = await correrBanco(elegidos, conLimites, {
370 concurrencia: 3,
371 alAvanzar: async (hechos, _total, parciales) => {
372 // Un guardado parcial que falla no corta el banco: el final vuelve a intentar.
373 if (hechos % 25 === 0) await guardar(parciales).catch(() => {})
374 },
375 })
376 await guardar(resultados)
377
378 const cuenta = (fuente: 'haiku' | 'jev') => {
379 const rs = resultados.filter(r => r.fuente === fuente)
380 const errores = rs.filter(r => r.error !== null).length
381 return ` · ${fuente} ${rs.length - errores} ok / ${errores} error`
382 }
383 const partes = (['haiku', 'jev'] as const).filter(f => clasificadores[f]).map(cuenta).join('')
384 return `Banco: ${elegidos.length} ${elegidos.length === 1 ? 'caso' : 'casos'}${partes} · ${relativa}${aviso}`
385}
386
387/** El modo hot se guarda en el store: vale para todas las sesiones. Arranca apagado. */
388const CLAVE_HOT = 'hot'
389
390export async function leerHot($: EngineInterface): Promise<boolean> {
391 try {
392 return (await $.store.get(CLAVE_HOT)) === true
393 } catch {
394 // Sin store (o si falla), apagado: la política nunca sube sin que el usuario la prenda.
395 return false
396 }
397}
398
399export async function guardarHot($: EngineInterface, valor: boolean): Promise<void> {
400 await $.store.set(CLAVE_HOT, valor)
401}
402
403export const register: Register = (on, options) => {
404 const umbrales = umbralesDe(options)
405 const tabla = tablaDe(options)
406
407 on('session.start', async ($, e, next) => {
408 await $.command.register({
409 name: 'router',
410 description: 'Router de subagentes: panel, on, off, reset, hot, allow, set, history y bench',
411 })
412 return next(e)
413 })
414
415 on('prompt.compose', async (_$, e, next) => {
416 const r = await next(e)
417 return { sections: [...r.sections, { id: 'router:pin', text: LINEA_ROUTER, scope: 'session' }] }
418 })
419
420 // La convención de nombres va en la tool Agent: Claude la lee justo cuando escribe la
421 // description. Si el hook falla, sale la descripción que calculó el host.
422 on('tool.describe', { tool: 'Agent' }, async (_$, e, next) => {
423 const r = await next(e)
424 return { ...r, description: `${r.description}
425
426${CONVENCION_AGENT}` }
427 })
428 .catch((_$, e, next) => next(e))
429
430 on('session.measure', async ($, e, next) => {
431 const lectura = lecturaDe(e.rateLimits)
432 if (lectura) {
433 await update($, lecturaAtom, () => lectura)
434 // En segundo plano y en cola: guardar la ventana nunca frena ni rompe la medición.
435 void guardarVentana($, lectura)
436 }
437 await refrescarLinea($)
438 return next(e)
439 })
440
441 on('agent.spawn', async ($, e, next) => {
442 const ahora = await $.clock.now()
443 let decision: Decision
444 let modo: Subagente['modo']
445 try {
446 const [overrides, lectura, hot, subs] = await Promise.all([read($, overridesAtom), read($, lecturaAtom), leerHot($), read($, subagentesAtom)])
447 const politica = politicaSegunHot(hot, lectura, ahora, umbrales)
448 modo = politica.modo
449 const { clasificar, jevDecideReviews } = await armarClasificador($, options, next.signal)
450 decision = await decidir(
451 {
452 descripcion: e.description, prompt: e.prompt, subagentType: e.subagentType, modeloPedido: e.model, fork: e.fork,
453 tallaBuildDeTarea: tallaBuildDe(subs, tareaDe(e.description)),
454 },
455 { overrides, politica, clasificar, jevDecideReviews, tabla },
456 )
457 } catch (error) {
458 // Nunca bloquea: si el router falla, el subagente sale con lo que pidió Claude.
459 decision = { tocar: false, motivos: [motivoError(error)] }
460 }
461
462 // Se reescribe con el alias ('opus', 'sonnet', 'haiku'): un consumidor interno de Claude Code
463 // valida `model` contra los alias; con un id completo logueaba un ZodError. El alias resuelve
464 // al modelo vigente.
465 const resultado = decision.tocar ? await next({ ...e, model: decision.escalon.modelo }) : await next(e)
466
467 if (resultado.agentId !== undefined) {
468 const pedido = e.model ?? 'inherit'
469 // Un subagente que el router no tocó ([pin], /router off, un error) igual entra en las
470 // métricas, con el tipo de las reglas, como en la línea base. Un build sin talla queda null.
471 const porReglas = e.fork ? null : (clasificarPorReglas(e.description, e.subagentType)?.tipo ?? null)
472 const sub: Subagente = {
473 agentId: resultado.agentId,
474 descripcion: e.description,
475 tarea: tareaDe(e.description),
476 tipo: decision.tocar ? decision.tipo : porReglas === 'build' ? null : porReglas,
477 pedido,
478 modeloEfectivo: e.model ?? e.parentModel,
479 elegido: decision.tocar ? formatEscalon(decision.escalon) : pedido,
480 escalon: decision.tocar ? decision.escalon : null,
481 motivos: decision.motivos,
482 // Sin model el subagente hereda el del padre: se compara contra ese, no contra 'inherit'.
483 reasignado: decision.tocar && !(e.model ?? e.parentModel).toLowerCase().includes(decision.escalon.modelo),
484 inicio: ahora,
485 fin: null,
486 ...(modo ? { modo } : {}),
487 toolUseId: e.tool_use_id,
488 }
489 try {
490 await update($, subagentesAtom, lista => [...lista, sub].slice(-200))
491 await refrescarLinea($)
492 } catch {
493 // Registrar es secundario: el subagente ya salió.
494 }
495 }
496 return resultado
497 })
498 // Red de seguridad solo para fallas ANTES de next (un $ que falla, un hook que se pasa de
499 // tiempo): ahí el subagente sale con lo que pidió Claude. Si next ya se llamó, next es
500 // replay-safe y el .catch repite su resultado o su rechazo; no reintenta con otro modelo.
501 .catch(($, e, next) => next(e))
502
503 // Avisos en el resultado de Agent (`context`: lo lee el modelo y el usuario no): avisarAClaude
504 // (apagado por defecto) dice que el router reasignó el subagente; ensenarConvencion (prendido
505 // por defecto) dice cómo nombrarlo cuando ninguna regla reconoció la description. Con las dos
506 // apagadas el hook ni se registra: el camino de Agent queda intacto. El spawn corre dentro de
507 // next: al volver, el subagente ya está en el atom, unido por el tool_use_id.
508 const avisar = options.avisarAClaude === true
509 const ensenar = options.ensenarConvencion !== false
510 if (avisar || ensenar) on('tool.call', { tool: 'Agent' }, async ($, e, next) => {
511 const r = await next(e)
512 if (r.deny !== undefined) return r
513 try {
514 const sub = (await read($, subagentesAtom)).find(s => s.toolUseId === e.tool_use_id)
515 if (!sub) return r
516 const lineas: string[] = []
517 if (avisar && sub.reasignado) lineas.push(avisoReasignado(sub))
518 const aviso = ensenar ? avisoConvencion(sub) : null
519 if (aviso) lineas.push(aviso)
520 return lineas.length ? { ...r, context: [...(r.context ?? []), ...lineas] } : r
521 } catch {
522 // Avisar es secundario: el resultado sale igual.
523 return r
524 }
525 })
526 // Como en agent.spawn: si el hook falla, next es replay-safe y el resultado sale sin aviso.
527 .catch(($, e, next) => next(e))
528
529 on('turn.step', async function* ($, e, next) {
530 if (e.agentId === undefined) return yield* next(e)
531 const subs = await read($, subagentesAtom)
532 const escalon = subs.find(s => s.agentId === e.agentId)?.escalon
533 // Solo si el paso corre en el modelo elegido (alias o id resuelto): tras un fallback de
534 // modelo no se fuerza un esfuerzo pensado para otro.
535 const aplica = escalon?.esfuerzo !== undefined && e.model.toLowerCase().includes(escalon.modelo)
536 return yield* next(aplica ? { ...e, effort: escalon!.esfuerzo } : e)
537 })
538
539 on('turn.complete', async ($, e, next) => {
540 if (e.agentId !== undefined) {
541 const ahora = await $.clock.now()
542 // El que se marca ahora (y no uno que ya había terminado): ese va al historial.
543 let terminado = undefined as Subagente | undefined
544 await update($, subagentesAtom, lista => {
545 // update puede reintentar el callback: se arranca de cero para no registrarlo dos veces.
546 terminado = undefined
547 return lista.map(s => (s.agentId === e.agentId && s.fin === null ? (terminado = { ...s, fin: ahora }) : s))
548 })
549 // Al historial, en segundo plano y en cola: guardarRegistro nunca rechaza ni frena el hook.
550 if (terminado) void guardarRegistro($, terminado)
551 await refrescarLinea($)
552 }
553 return next(e)
554 })
555
556 on('command.run', { command: 'router' }, async ($, e) => {
557 const comando = parsearComando(e.args)
558 if (comando.accion === 'panel') {
559 await $.ui.open({ id: PANE, title: 'Router' })
560 return { text: describirComando(comando) }
561 }
562 if (comando.accion === 'banco') {
563 try {
564 return { text: await correrBancoEnSesion($, options, comando) }
565 } catch (error) {
566 return { text: `Banco: falló (${error instanceof Error ? error.message : String(error)})` }
567 }
568 }
569 if (comando.accion === 'hot') {
570 if (comando.valor === null) {
571 return {
572 text: (await leerHot($))
573 ? 'Router: modo hot prendido. Apagalo con /router hot off.'
574 : 'Router: modo hot apagado. Prendelo con /router hot on.',
575 }
576 }
577 try {
578 await guardarHot($, comando.valor)
579 } catch (error) {
580 return { text: `Router: no pude guardar el modo hot (${error instanceof Error ? error.message : String(error)})` }
581 }
582 return { text: describirComando(comando) }
583 }
584 if (comando.accion === 'historial') {
585 try {
586 return { text: await exportarHistorial($, comando.dias) }
587 } catch (error) {
588 return { text: `Router: no pude exportar el historial (${error instanceof Error ? error.message : String(error)})` }
589 }
590 }
591 if (comando.accion !== 'error') {
592 await update($, overridesAtom, o => aplicarComando(o, comando))
593 await refrescarLinea($)
594 }
595 return { text: describirComando(comando) }
596 })
597
598 // El panel: este hook lee los atoms y src/ui/panel.tsx arma el árbol sin tocar $.
599 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
600 const [pestana, subs, overrides, lectura, ahora, muestras, guardado, vistas, sesion, hot] = await Promise.all([
601 read($, pestanaAtom), read($, subagentesAtom), read($, overridesAtom), read($, lecturaAtom), $.clock.now(),
602 // Sin store (o si falla), el panel sale igual: sin la sección de sombra y con "—" en la
603 // columna de 7 días y en la ventana sin usar.
604 $.store.get('sombra').catch(() => undefined),
605 leerHistoriales($).catch(() => [] as HistorialSesion[]),
606 $.store.get('ventanas').catch(() => undefined),
607 $.session.id().catch(() => null),
608 leerHot($),
609 ])
610 const historial = guardado.flatMap(s => s.registros ?? [])
611 const ventanas = ventanasDe(vistas)
612 const SEMANA = 7 * 86_400_000
613 const decisiones = ultimasDecisiones(subs, sesion, historial)
614 return vistaPanel($.ui.resolve(e), {
615 pestana,
616 subs,
617 overrides,
618 politica: politicaSegunHot(hot, lectura, ahora, umbrales),
619 ahora,
620 bodyColumns: e.props.bodyColumns,
621 sombra: estadisticasSombra(muestrasDe(muestras)),
622 semana: metricasHistorial(ultimosDias(historial, ahora, 7)),
623 ventanas7: resumenVentanas(ventanas, ahora - SEMANA, ahora),
624 ventanasAntes: resumenVentanas(ventanas, ahora - 2 * SEMANA, ahora - SEMANA),
625 decisiones: decisiones.slice(0, DECISIONES_EN_PANEL),
626 // Por el momento de la decisión (el spawn), así cuentan también las que siguen corriendo.
627 resumen7: resumenDecisiones(decisiones.filter(f => f.inicio > ahora - SEMANA && f.inicio <= ahora)),
628 alElegirPestana: p => update($, pestanaAtom, () => p),
629 })
630 })
631}
632src/banco.ts 80 lines1import type { Tipo } from '../types'
2import type { Clasificador } from './clasificadores'
3import type { Resumen } from './resumen'
4
5export type Caso = { id: string; descripcion: string; resumen: Resumen; etiquetasProduccion: Tipo[] | null }
6export type ResultadoBanco = { id: string; fuente: 'haiku' | 'jev'; tipo: string | null; confianza: number | null; ms: number; error: string | null }
7
8export function casosDeProduccion<C extends Caso>(casos: readonly C[]): C[] {
9 return casos.filter(c => c.etiquetasProduccion !== null)
10}
11
12/**
13 * Corre cada caso de producción por cada clasificador, sin tope: el banco mide la latencia real.
14 * Los errores quedan en `error`. `alAvanzar` recibe además los resultados hasta ahora, para que
15 * quien llama pueda ir guardando la corrida.
16 */
17export async function correrBanco(
18 casos: readonly Caso[],
19 clasificadores: Partial<Record<'haiku' | 'jev', Clasificador>>,
20 opciones: {
21 concurrencia: number
22 alAvanzar?: (hechos: number, total: number, parciales: readonly ResultadoBanco[]) => Promise<void>
23 },
24): Promise<ResultadoBanco[]> {
25 const trabajos = casosDeProduccion(casos).flatMap(c =>
26 (Object.entries(clasificadores) as ['haiku' | 'jev', Clasificador][]).map(([fuente, cl]) => ({ c, fuente, cl })),
27 )
28 const resultados: ResultadoBanco[] = []
29 let siguiente = 0
30 const trabajador = async () => {
31 while (siguiente < trabajos.length) {
32 const { c, fuente, cl } = trabajos[siguiente++]!
33 try {
34 const r = await cl({ descripcion: c.descripcion, resumen: c.resumen, etiquetas: c.etiquetasProduccion! })
35 resultados.push({ id: c.id, fuente, tipo: r.tipo, confianza: r.confianza ?? null, ms: r.ms, error: null })
36 } catch (e) {
37 resultados.push({ id: c.id, fuente, tipo: null, confianza: null, ms: 0, error: e instanceof Error ? e.message : String(e) })
38 }
39 await opciones.alAvanzar?.(resultados.length, trabajos.length, resultados.slice())
40 }
41 }
42 await Promise.all(Array.from({ length: Math.max(1, opciones.concurrencia) }, trabajador))
43 return resultados
44}
45
46/**
47 * Ruta de la corrida con fecha y hora LOCALES: una prueba con --max no pisa una corrida entera y
48 * una corrida de noche queda con la fecha del día. Ordena bien como texto.
49 */
50export function nombreCorrida(fechaLocal: Date): string {
51 const dos = (n: number) => String(n).padStart(2, '0')
52 const d = fechaLocal
53 return `bench/corridas/${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}-${dos(d.getHours())}${dos(d.getMinutes())}-banco.json`
54}
55
56/**
57 * Cola que corre las tareas de a una, en el orden en que llegan: un guardado lento no pisa a uno
58 * posterior. Cada tarea devuelve su propio resultado o rechazo; un rechazo no corta la cola.
59 */
60export function colaEnOrden(): <T>(tarea: () => Promise<T>) => Promise<T> {
61 let ultima: Promise<unknown> = Promise.resolve()
62 return tarea => {
63 const r = ultima.then(tarea)
64 ultima = r.catch(() => {})
65 return r
66 }
67}
68
69/**
70 * Límite por llamada del banco: si el clasificador no responde en `ms`, falla con
71 * "tardó más de N s" (a diferencia del tope de producción, no decide el respaldo).
72 */
73export function conLimite(c: Clasificador, ms: number, dormir: (ms: number) => Promise<void>): Clasificador {
74 return async p => {
75 const r = await Promise.race([c(p), dormir(ms).then(() => 'tarde' as const)])
76 if (r === 'tarde') throw new Error(`tardó más de ${Math.round(ms / 1000)} s`)
77 return r
78 }
79}
80src/clasificadores/index.ts 45 lines1import type { Tipo } from '../../types'
2import type { Resumen } from '../resumen'
3import { tamanoPorRespaldo } from '../resumen'
4
5export type Clasificacion = { tipo: Tipo; confianza?: number; ms: number; fuente: 'respaldo' | 'haiku' | 'jev'; detalle?: string }
6
7export type PedidoClasificacion = { descripcion: string; resumen: Resumen; etiquetas: readonly Tipo[] }
8
9export type Clasificador = (p: PedidoClasificacion) => Promise<Clasificacion>
10
11export const TALLAS: readonly Tipo[] = ['build-large', 'build-small']
12
13export const TALLAS_REVIEW: readonly Tipo[] = ['review-large', 'review-small']
14
15const mismasEtiquetas = (etiquetas: readonly Tipo[], par: readonly Tipo[]) =>
16 etiquetas.length === par.length && par.every(t => etiquetas.includes(t))
17
18/** Una de las dos preguntas de talla: build-large/build-small o review-large/review-small. */
19export function esPreguntaDeTalla(etiquetas: readonly Tipo[]): boolean {
20 return mismasEtiquetas(etiquetas, TALLAS) || mismasEtiquetas(etiquetas, TALLAS_REVIEW)
21}
22
23/** Decide sin modelo: por tamaño en la talla de un build; review-small en la de una review; si no hubo regla, build-small. */
24export const respaldo: Clasificador = async ({ resumen, etiquetas }) => ({
25 tipo: mismasEtiquetas(etiquetas, TALLAS) ? tamanoPorRespaldo(resumen) : mismasEtiquetas(etiquetas, TALLAS_REVIEW) ? 'review-small' : 'build-small',
26 ms: 0,
27 fuente: 'respaldo',
28})
29
30export const TOPE_MS = { min: 50, max: 8000, porDefecto: 1000 } as const
31// 0.7: calibrado en el banco del 2026-10-07; las cifras están en la tabla "Umbral de Jev" de
32// bench/corridas/2026-10-07-1032-comparacion.md.
33export const JEV_UMBRAL = { min: 0, max: 1, porDefecto: 0.7 } as const
34
35const acotar = (x: unknown, l: { min: number; max: number; porDefecto: number }) =>
36 typeof x === 'number' && Number.isFinite(x) ? Math.min(l.max, Math.max(l.min, x)) : l.porDefecto
37
38/**
39 * Los límites de /config, acotados: `topeMs` a [50, 8000] porque el hook tiene 10 s de
40 * presupuesto y la espera cuenta; `jevUmbral` a [0, 1]. Un valor que no es número toma el default.
41 */
42export function limitesDe(options: { topeMs?: unknown; jevUmbral?: unknown }): { topeMs: number; jevUmbral: number } {
43 return { topeMs: acotar(options.topeMs, TOPE_MS), jevUmbral: acotar(options.jevUmbral, JEV_UMBRAL) }
44}
45src/clasificadores/haiku.ts 20 lines1import type { Tipo } from '../../types'
2import { textoResumen } from '../resumen'
3import type { Clasificador } from './index'
4import { textoParaHaiku } from './preguntas'
5
6export type Reloj = () => Promise<number>
7export type FuncionClasificar = (texto: string, etiquetas: readonly string[]) => Promise<string | undefined>
8
9export function crearHaiku(clasificar: FuncionClasificar, ahora: Reloj): Clasificador {
10 return async ({ descripcion, resumen, etiquetas }) => {
11 const inicio = await ahora()
12 const r = await clasificar(textoParaHaiku(textoResumen(descripcion, resumen), etiquetas), etiquetas)
13 const ms = (await ahora()) - inicio
14 if (r === undefined || !(etiquetas as readonly string[]).includes(r)) {
15 throw new Error(`haiku no eligió una etiqueta válida: ${String(r)}`)
16 }
17 return { tipo: r as Tipo, ms, fuente: 'haiku' }
18 }
19}
20src/clasificadores/jev.ts 50 lines1import type { Tipo } from '../../types'
2import { textoResumen } from '../resumen'
3import type { Reloj } from './haiku'
4import type { Clasificador } from './index'
5import { esPreguntaDeTalla } from './index'
6import { preguntaJev } from './preguntas'
7
8export const JEV_URL = 'https://api.typesafe.ai/v1/systemone'
9/** Fijo: jev-latest cambia de modelo sin aviso y los umbrales se calibran contra una versión. */
10export const JEV_MODELO = 'jev-1.13.0'
11
12export type Post = (cuerpo: string) => Promise<{ ok: boolean; status: number; text: string }>
13
14export function pedidoJev(tarea: string, etiquetas: readonly Tipo[]) {
15 return { model: JEV_MODELO, state: { tarea }, questions: { tipo: preguntaJev(etiquetas) } }
16}
17
18export function leerJev(texto: string, etiquetas: readonly Tipo[]): { tipo: Tipo; confianza: number } {
19 let json: { answers?: { tipo?: { type?: string; choice?: unknown; confidence?: unknown; probabilities?: Record<string, number> } } }
20 try {
21 json = JSON.parse(texto)
22 } catch {
23 throw new Error('jev: la respuesta no es JSON')
24 }
25 const a = json.answers?.tipo
26 if (!a || a.type !== 'choice' || typeof a.choice !== 'string') throw new Error('jev: la respuesta no trae answers.tipo')
27 if (!(etiquetas as readonly string[]).includes(a.choice)) throw new Error(`jev eligió una opción que no se pidió: ${a.choice}`)
28 const confianza = typeof a.confidence === 'number' ? a.confidence : (a.probabilities?.[a.choice] ?? 0)
29 return { tipo: a.choice as Tipo, confianza }
30}
31
32export function crearJev(post: Post, ahora: Reloj, umbral: number): Clasificador {
33 return async ({ descripcion, resumen, etiquetas }) => {
34 const inicio = await ahora()
35 const r = await post(JSON.stringify(pedidoJev(textoResumen(descripcion, resumen), etiquetas)))
36 const ms = (await ahora()) - inicio
37 if (!r.ok) throw new Error(`jev: HTTP ${r.status}`)
38 const { tipo, confianza } = leerJev(r.text, etiquetas)
39 if (confianza < umbral) {
40 // El umbral se calibró para la talla de los builds y para las reviews por description (si Jev
41 // duda ahí, queda la regla). Sin regla, el respaldo siempre dice build-small y acierta menos
42 // que Jev con duda. Cifras: bench/corridas/2026-10-07-1032-comparacion.md (talla y sin regla) y
43 // bench/corridas/2026-10-09-1339-jev-todos-analisis.md (reviews; 0.7 simulado el 2026-10-09).
44 if (esPreguntaDeTalla(etiquetas)) throw new Error(`jev dudó (${confianza.toFixed(2)})`)
45 return { tipo, confianza, ms, fuente: 'jev', detalle: 'sin umbral' }
46 }
47 return { tipo, confianza, ms, fuente: 'jev' }
48 }
49}
50src/clasificadores/sombra.ts 152 lines1import type { Tipo } from '../../types'
2import { textoResumen } from '../resumen'
3import type { Reloj } from './haiku'
4import type { Clasificacion, Clasificador } from './index'
5
6export type Nombres = { principal: string; sombra: string }
7
8export type Muestra = {
9 fecha: number
10 tarea: string
11 etiquetas: Tipo[]
12 nombres: Nombres
13 principal: Clasificacion
14 sombra: Clasificacion
15 /** La sombra respondió por su cuenta, pero tardó más que `topeMs`: en producción habría decidido el respaldo. */
16 pasoTope: boolean
17}
18
19/**
20 * Decide el principal y lo devuelve apenas responde. La sombra corre en paralelo y en segundo
21 * plano: nunca se espera, y cualquier fallo suyo (o del reloj o del registro) se traga.
22 * `topeMs` no corta a la sombra: solo marca en la muestra si se pasó (`pasoTope`).
23 */
24export function conSombra(
25 principal: Clasificador,
26 sombra: Clasificador,
27 registrar: (m: Muestra) => Promise<void>,
28 ahora: Reloj,
29 nombres: Nombres,
30 topeMs: number,
31): Clasificador {
32 return p => {
33 const a = principal(p)
34 void (async () => {
35 const b = await sombra(p)
36 const resultado = await a
37 const muestra: Muestra = {
38 fecha: await ahora(),
39 tarea: textoResumen(p.descripcion, p.resumen),
40 etiquetas: [...p.etiquetas],
41 nombres,
42 principal: resultado,
43 sombra: b,
44 pasoTope: b.fuente !== 'respaldo' && b.ms > topeMs,
45 }
46 await registrar(muestra)
47 })().catch(() => {})
48 return a
49 }
50}
51
52export function agregarMuestra(lista: readonly Muestra[], m: Muestra, max = 500): Muestra[] {
53 return [...lista, m].slice(-max)
54}
55
56/**
57 * Un clasificador dentro de un par. p50/p95 miden solo las respuestas propias (sin respaldo) y
58 * son null si no hay ninguna, o si es `reglas` (decide sin modelo). `pasoTope` es null para el
59 * principal: a él lo corta el tope, y esos cortes ya cuentan como respaldos.
60 */
61export type PorClasificador = {
62 nombre: string
63 n: number
64 p50: number | null
65 p95: number | null
66 respaldos: number
67 pasoTope: number | null
68}
69
70/**
71 * Un par principal→sombra. Una muestra es comparable si la sombra respondió por su cuenta y el
72 * principal también (o es `reglas`, cuyo respaldo es su forma normal de decidir): comparar contra
73 * un respaldo forzado por el tope o por un error mediría el respaldo, no al clasificador.
74 */
75export type EstadisticasPar = {
76 par: string
77 n: number
78 comparables: number
79 coinciden: number
80 principal: PorClasificador
81 sombra: PorClasificador
82}
83
84export type Estadisticas = { n: number; pares: EstadisticasPar[] }
85
86const percentil = (xs: number[], q: number): number | null => {
87 if (xs.length === 0) return null
88 const s = [...xs].sort((a, b) => a - b)
89 return s[Math.min(s.length - 1, Math.floor(q * s.length))]!
90}
91
92function porClasificador(nombre: string, rs: readonly Clasificacion[], pasoTope: number | null): PorClasificador {
93 if (nombre === 'reglas') return { nombre, n: rs.length, p50: null, p95: null, respaldos: 0, pasoTope }
94 const propias = rs.filter(r => r.fuente !== 'respaldo').map(r => r.ms)
95 return {
96 nombre,
97 n: rs.length,
98 p50: percentil(propias, 0.5),
99 p95: percentil(propias, 0.95),
100 respaldos: rs.length - propias.length,
101 pasoTope,
102 }
103}
104
105export function esComparable(m: Muestra): boolean {
106 return m.sombra.fuente !== 'respaldo' && (m.nombres.principal === 'reglas' || m.principal.fuente !== 'respaldo')
107}
108
109/** Estadísticas por par principal→sombra (al cambiar /config se mezclan pares), en orden de aparición. */
110export function estadisticasSombra(lista: readonly Muestra[]): Estadisticas | null {
111 if (lista.length === 0) return null
112 const grupos = new Map<string, Muestra[]>()
113 for (const m of lista) {
114 const par = `${m.nombres.principal}→${m.nombres.sombra}`
115 grupos.set(par, [...(grupos.get(par) ?? []), m])
116 }
117 const pares = [...grupos].map(([par, ms]): EstadisticasPar => {
118 const comparables = ms.filter(esComparable)
119 return {
120 par,
121 n: ms.length,
122 comparables: comparables.length,
123 coinciden: comparables.filter(m => m.principal.tipo === m.sombra.tipo).length,
124 principal: porClasificador(ms[0]!.nombres.principal, ms.map(m => m.principal), null),
125 sombra: porClasificador(ms[0]!.nombres.sombra, ms.map(m => m.sombra), ms.filter(m => m.pasoTope).length),
126 }
127 })
128 return { n: lista.length, pares }
129}
130
131const esObjeto = (x: unknown): x is Record<string, unknown> => typeof x === 'object' && x !== null
132
133/**
134 * Lo que hay en el store, ya validado: el store sobrevive a versiones del plugin y a ediciones a
135 * mano, así que algo que no es lista vale como lista vacía y una muestra rota se descarta. Una
136 * muestra de antes de `pasoTope` lo toma como false.
137 */
138export function muestrasDe(valor: unknown): Muestra[] {
139 if (!Array.isArray(valor)) return []
140 return valor
141 .filter(
142 (m): m is Muestra =>
143 esObjeto(m) &&
144 esObjeto(m.nombres) &&
145 typeof m.nombres.principal === 'string' &&
146 typeof m.nombres.sombra === 'string' &&
147 esObjeto(m.principal) &&
148 esObjeto(m.sombra),
149 )
150 .map(m => ({ ...m, pasoTope: m.pasoTope === true }))
151}
152src/clasificadores/tope.ts 20 lines1import type { Clasificador } from './index'
2import { respaldo } from './index'
3
4export type Dormir = (ms: number) => Promise<void>
5
6/** Nunca tarda más que `ms` ni falla: si el clasificador se pasa o falla, decide el respaldo. */
7export function conTope(c: Clasificador, ms: number, dormir: Dormir): Clasificador {
8 return async p => {
9 try {
10 const tarde = dormir(ms).then(() => 'tarde' as const)
11 const r = await Promise.race([c(p), tarde])
12 if (r !== 'tarde') return r
13 return { ...(await respaldo(p)), detalle: `tardó más de ${ms} ms` }
14 } catch (error) {
15 const mensaje = error instanceof Error ? error.message : String(error)
16 return { ...(await respaldo(p)), detalle: mensaje.slice(0, 80) }
17 }
18 }
19}
20src/convencion.ts 32 lines1import type { Subagente } from '../types'
2
3/**
4 * Se suma a la descripción de la tool Agent: ahí lo lee Claude justo cuando escribe la
5 * `description`. Constante a propósito: el host cachea tool.describe por sesión y un texto que
6 * cambia gasta el caché del prompt.
7 */
8export const CONVENCION_AGENT =
9 'Router de subagentes activo. El router elige el modelo y el esfuerzo de cada subagente: omití ' +
10 '`model` (se ignora salvo que la description tenga `[pin]`), aunque te hayan pedido un modelo. ' +
11 'Empezá `description` con el verbo del tipo de tarea: Implement (construir o editar), Review ' +
12 '(verificar o revisar sin editar), Re-review (revisar de nuevo tras un arreglo), Fix (arreglar ' +
13 'hallazgos), Explore (leer o mapear código), Research (investigar afuera), Write (redactar ' +
14 'notas o docs). Si es parte de un plan, nombrá la unidad como `Task N` y repetí el mismo número ' +
15 'en su build, review y fix. Ejemplos: "Implement Task 3: kubernetes", "Review Task 3: ' +
16 'kubernetes".'
17
18/** La description no dijo el tipo: ninguna regla la reconoció y decidió un clasificador. */
19export function sinReglaDeDescription(motivos: readonly string[]): boolean {
20 return motivos.includes('ninguna regla')
21}
22
23/** La línea que ve Claude en el resultado de Agent cuando no siguió la convención. */
24export function avisoConvencion(s: Subagente): string | null {
25 if (s.escalon === null || s.tipo === null || !sinReglaDeDescription(s.motivos)) return null
26 const d = s.descripcion.length > 60 ? `${s.descripcion.slice(0, 59)}…` : s.descripcion
27 return (
28 `router: no reconocí el tipo de "${d}" y lo clasifiqué como ${s.tipo}. Empezá la description ` +
29 'con el verbo del tipo (Implement, Review, Re-review, Fix, Explore, Research, Write) y usá `Task N`.'
30 )
31}
32src/decidir.ts 128 lines1import type { Escalon, Modelo, Overrides, Tipo } from '../types'
2import type { Clasificacion, Clasificador } from './clasificadores'
3import { TALLAS } from './clasificadores'
4import { tienePin } from './overrides'
5import type { EstadoPolitica } from './politica'
6import { subir } from './politica'
7import { clasificarPorReglas } from './reglas'
8import { resumir } from './resumen'
9import { tallaDeReview } from './tallaReview'
10import type { Tabla } from './taxonomia'
11import { BASE, ESCALERA, formatEscalon, TECHO, TIPOS, TOPE } from './taxonomia'
12
13export type EntradaDecision = {
14 descripcion: string
15 prompt: string
16 subagentType: string
17 modeloPedido: string | undefined
18 fork: boolean
19 /** La talla del último build de la misma tarea en la sesión. */
20 tallaBuildDeTarea?: 'build-large' | 'build-small' | null
21}
22
23export type Contexto = { overrides: Overrides; politica: EstadoPolitica; clasificar: Clasificador; tabla?: Tabla; jevDecideReviews: boolean }
24
25export type Decision =
26 | { tocar: false; motivos: string[] }
27 | { tocar: true; tipo: Tipo; escalon: Escalon; motivos: string[] }
28
29/** El motivo de una decisión que falló, recortado a 80 caracteres como el detalle del clasificador. */
30export function motivoError(error: unknown): string {
31 return `error del router: ${String(error)}`.slice(0, 80)
32}
33
34const REVIEWS: readonly Tipo[] = ['review-final', 'review', 're-review']
35
36/** Lo que pasó al consultar a Jev por una review: el tipo final y, si corresponde, el motivo. */
37export function revisarConJev(regla: Tipo, cl: Clasificacion): { tipo: Tipo; motivo: string | null } {
38 const conf = cl.confianza === undefined ? '' : ` (${cl.confianza.toFixed(2)})`
39 // Una review no baja a Haiku por Jev: explore y write dejan la regla.
40 if (cl.fuente === 'jev' && cl.detalle === undefined && (cl.tipo === 'explore' || cl.tipo === 'write'))
41 return { tipo: regla, motivo: `jev → ${cl.tipo}${conf}: queda la regla` }
42 if (cl.fuente === 'jev' && cl.detalle === undefined) return { tipo: cl.tipo, motivo: `jev → ${cl.tipo}${conf}` }
43 if (cl.fuente === 'jev') return { tipo: regla, motivo: `jev dudó${conf}: queda la regla` }
44 // Hoy inalcanzable (solo se consulta con Jev como clasificador principal); queda como defensa.
45 if (cl.fuente === 'haiku') return { tipo: regla, motivo: `haiku → ${cl.tipo}: queda la regla` }
46 return { tipo: regla, motivo: cl.detalle ? `respaldo (${cl.detalle}): queda la regla` : null }
47}
48
49/** Cuánto subió la política de verdad, ya con el techo aplicado. */
50export function motivoPolitica(base: number, i: number): string | null {
51 return i > base ? `política +${i - base}` : null
52}
53
54export async function decidir(e: EntradaDecision, c: Contexto): Promise<Decision> {
55 if (!c.overrides.activo) return { tocar: false, motivos: ['router apagado'] }
56 if (e.fork) return { tocar: false, motivos: ['fork: hereda el modelo'] }
57 if (tienePin(e.descripcion)) return { tocar: false, motivos: ['[pin]: respeta lo que pidió Claude'] }
58
59 const motivos: string[] = []
60 const regla = clasificarPorReglas(e.descripcion, e.subagentType)
61 let tipo: Tipo
62 if (regla && regla.tipo !== 'build') {
63 tipo = regla.tipo
64 motivos.push(`regla ${regla.regla}`)
65 // Jev decide la familia review (le gana a las reglas en el banco): si está seguro, manda él.
66 if (c.jevDecideReviews && REVIEWS.includes(regla.tipo) && !regla.regla.startsWith('subagentType')) {
67 const cl = await c.clasificar({ descripcion: e.descripcion, resumen: resumir(e.prompt), etiquetas: TIPOS })
68 const r = revisarConJev(regla.tipo, cl)
69 tipo = r.tipo
70 if (r.motivo) motivos.push(r.motivo)
71 }
72 } else {
73 motivos.push(regla ? `regla ${regla.regla}` : 'ninguna regla')
74 const cl = await c.clasificar({
75 descripcion: e.descripcion,
76 resumen: resumir(e.prompt),
77 etiquetas: regla ? TALLAS : TIPOS,
78 })
79 tipo = cl.tipo
80 const detalle = cl.detalle ? ` (${cl.detalle})` : ''
81 const confianza = cl.confianza === undefined ? '' : ` (${cl.confianza.toFixed(2)})`
82 motivos.push(`${cl.fuente}${detalle} → ${cl.tipo}${confianza}`)
83 }
84
85 // La review recibe su talla antes de fijados, base y política: todo usa el tipo final.
86 if (tipo === 'review') {
87 const t = await tallaDeReview(e, c.clasificar)
88 tipo = t.tipo
89 motivos.push(t.motivo)
90 }
91
92 let escalon: Escalon
93 // Un `set review` viejo (guardado en el atom antes de las tallas) vale para las dos tallas.
94 const fijado = c.overrides.fijados[tipo] ?? ((tipo === 'review-large' || tipo === 'review-small') ? c.overrides.fijados.review : undefined)
95 if (fijado) {
96 escalon = fijado
97 motivos.push(`fijado con /router: ${formatEscalon(fijado)}`)
98 } else {
99 const { base, techo } = c.tabla ?? { base: BASE, techo: TECHO }
100 if (base[tipo] !== BASE[tipo]) motivos.push(`base de /config: ${formatEscalon(ESCALERA[base[tipo]]!)}`)
101 const deseado = base[tipo] + c.politica.escalones
102 const i = subir(base[tipo], c.politica.escalones, techo[tipo])
103 const mp = motivoPolitica(base[tipo], i)
104 if (mp !== null) motivos.push(mp)
105 if (techo[tipo] !== undefined && deseado > techo[tipo]!) motivos.push(`techo de ${tipo}`)
106 escalon = ESCALERA[i]!
107 }
108
109 const final = ajustarAPermitidos(escalon, c.overrides.permitidos)
110 if (final !== escalon) motivos.push(`permitidos: ${c.overrides.permitidos!.join(',')}`)
111 return { tocar: true, tipo, escalon: final, motivos }
112}
113
114/** Si el modelo no está permitido, busca el escalón permitido más cercano; ante empate, sube. */
115export function ajustarAPermitidos(e: Escalon, permitidos: readonly Modelo[] | null): Escalon {
116 if (!permitidos || permitidos.includes(e.modelo)) return e
117 const exacto = ESCALERA.findIndex(x => x.modelo === e.modelo && x.esfuerzo === e.esfuerzo)
118 // Un escalón fijado fuera de la escalera (opus·max) arranca desde el más alto de su modelo.
119 const desde = exacto !== -1 ? exacto : ESCALERA.map(x => x.modelo).lastIndexOf(e.modelo)
120 for (let d = 1; d <= TOPE; d++) {
121 const arriba = ESCALERA[desde + d]
122 if (arriba && permitidos.includes(arriba.modelo)) return arriba
123 const abajo = ESCALERA[desde - d]
124 if (abajo && permitidos.includes(abajo.modelo)) return abajo
125 }
126 return e
127}
128src/historial.ts 303 lines1import type { Modelo, Subagente, Tipo } from '../types'
2import type { Modo } from './politica'
3import { metricasDe, type MetricasSesion } from './registro'
4import { MODELOS } from './taxonomia'
5
6/** Un subagente terminado, en forma compacta, para guardarlo entre sesiones. */
7export type Registro = {
8 sesion: string
9 tarea: string | null
10 tipo: Tipo | null
11 modelo: Modelo | null
12 pedido: string
13 reasignado: boolean
14 inicio: number
15 fin: number
16 // Los campos de la decisión. Opcionales: los registros guardados antes de que existieran no los tienen.
17 /** La description del subagente, truncada a `MAX_DESCRIPCION` caracteres. */
18 descripcion?: string
19 /** El escalón como texto (`opus·xhigh`), o el pedido si el router no lo tocó. */
20 elegido?: string
21 /** La cadena del router: `regla review`, `jev → build-large (0.83)`, `política +2`, `[pin]: …`. */
22 motivos?: string[]
23 /** La política al momento del spawn. */
24 modo?: Modo
25}
26
27export const MAX_DESCRIPCION = 100
28const MODOS: readonly Modo[] = ['fuego', 'sobra', 'normal']
29
30/** Tope de registros: por sesión y, sumando todas las sesiones, el que dispara la poda. */
31export const MAX_HISTORIAL = 3000
32/** Una sesión cuyo último registro tiene esta antigüedad o más se borra al podar. */
33export const DIAS_HISTORIAL = 14
34
35/** La clave única de antes de guardar por sesión; se migra a `CLAVE_LEGADO` y se borra. */
36export const CLAVE_VIEJA = 'historial'
37const PREFIJO = 'historial:'
38/** Cada sesión escribe solo la suya: dos procesos no se pisan el get → set. */
39export const claveHistorial = (sesion: string) => `${PREFIJO}${sesion}`
40/** Adonde va a parar la clave vieja al migrarla: queda como una sesión más. */
41export const CLAVE_LEGADO = claveHistorial('legado')
42/** Las claves que lee el panel: las de cada sesión y la vieja, si todavía no se migró. */
43export const esClaveHistorial = (clave: string) => clave === CLAVE_VIEJA || clave.startsWith(PREFIJO)
44
45function modeloDe(s: Subagente): Modelo | null {
46 if (s.escalon) return s.escalon.modelo
47 // Sin escalón, el modelo con el que corrió (el del padre si heredó), no el pedido.
48 const modelo = (s.modeloEfectivo ?? s.pedido).toLowerCase()
49 return MODELOS.find(m => modelo.includes(m)) ?? null
50}
51
52/** null si el subagente todavía no terminó. */
53export function registroDe(s: Subagente, sesion: string): Registro | null {
54 if (s.fin === null) return null
55 return {
56 sesion,
57 tarea: s.tarea,
58 tipo: s.tipo,
59 modelo: modeloDe(s),
60 pedido: s.pedido,
61 reasignado: s.reasignado,
62 inicio: s.inicio,
63 fin: s.fin,
64 descripcion: s.descripcion.slice(0, MAX_DESCRIPCION),
65 elegido: s.elegido,
66 motivos: [...s.motivos],
67 ...(s.modo ? { modo: s.modo } : {}),
68 }
69}
70
71export function agregarRegistro(lista: readonly Registro[], r: Registro, max = MAX_HISTORIAL): Registro[] {
72 return [...lista, r].slice(-max)
73}
74
75/**
76 * Valida lo leído del store: descarta lo que no tenga sesión, inicio o fin numéricos, pedido string
77 * o tarea string/null. Normaliza `reasignado` a booleano y `modelo` a uno válido o null. De los
78 * campos de la decisión (opcionales) quita el que venga malformado, sin tirar el registro.
79 */
80export function historialDe(valor: unknown): Registro[] {
81 if (!Array.isArray(valor)) return []
82 return valor
83 .filter(
84 (r): r is Registro =>
85 typeof r === 'object' &&
86 r !== null &&
87 typeof r.sesion === 'string' &&
88 typeof r.pedido === 'string' &&
89 (typeof r.tarea === 'string' || r.tarea === null) &&
90 typeof r.inicio === 'number' &&
91 Number.isFinite(r.inicio) &&
92 typeof r.fin === 'number' &&
93 Number.isFinite(r.fin),
94 )
95 .map(r => {
96 const limpio: Registro = {
97 ...r,
98 reasignado: r.reasignado === true,
99 modelo: (MODELOS as readonly unknown[]).includes(r.modelo) ? r.modelo : null,
100 }
101 const crudo = r as Record<string, unknown>
102 if (typeof crudo.descripcion !== 'string') delete limpio.descripcion
103 if (typeof crudo.elegido !== 'string') delete limpio.elegido
104 if (!Array.isArray(crudo.motivos) || !crudo.motivos.every(m => typeof m === 'string')) delete limpio.motivos
105 if (!(MODOS as readonly unknown[]).includes(crudo.modo)) delete limpio.modo
106 return limpio
107 })
108}
109
110/** Identifica un registro para no contarlo dos veces al juntar memoria y store. */
111const claveRegistro = (r: { sesion: string | null; inicio: number; pedido: string; descripcion?: string }) =>
112 `${r.sesion}|${r.inicio}|${r.pedido}|${(r.descripcion ?? '').slice(0, MAX_DESCRIPCION)}`
113
114/** `a` y después los de `b` que no estén en `a` (misma sesión, inicio, pedido y descripción). */
115export function unirRegistros(a: readonly Registro[], b: readonly Registro[]): Registro[] {
116 const vistos = new Set(a.map(claveRegistro))
117 return [...a, ...b.filter(r => !vistos.has(claveRegistro(r)))]
118}
119
120/** Una decisión del router como la muestran la pestaña Historial y el export. */
121export type FilaDecision = {
122 sesion: string | null
123 inicio: number
124 tarea: string | null
125 tipo: Tipo | null
126 descripcion: string
127 pedido: string
128 elegido: string
129 /** El modelo con el que corrió (o corre); null si no se sabe. */
130 modelo: Modelo | null
131 modo: Modo | null
132 motivos: string[]
133 reasignado: boolean
134 enCurso: boolean
135}
136
137/** Un registro guardado. Los viejos, sin los campos de la decisión, caen en el modelo y en vacío. */
138export function filaDeRegistro(r: Registro): FilaDecision {
139 return {
140 sesion: r.sesion,
141 inicio: r.inicio,
142 tarea: r.tarea,
143 tipo: r.tipo,
144 descripcion: r.descripcion ?? '',
145 pedido: r.pedido,
146 elegido: r.elegido ?? r.modelo ?? r.pedido,
147 modelo: r.modelo,
148 modo: r.modo ?? null,
149 motivos: r.motivos ?? [],
150 reasignado: r.reasignado,
151 enCurso: false,
152 }
153}
154
155/** Un subagente de la memoria de esta sesión, terminado o en curso. */
156export function filaDeSub(s: Subagente, sesion: string | null): FilaDecision {
157 return {
158 sesion,
159 inicio: s.inicio,
160 tarea: s.tarea,
161 tipo: s.tipo,
162 descripcion: s.descripcion.slice(0, MAX_DESCRIPCION),
163 pedido: s.pedido,
164 elegido: s.elegido,
165 modelo: modeloDe(s),
166 modo: s.modo ?? null,
167 motivos: s.motivos,
168 reasignado: s.reasignado,
169 enCurso: s.fin === null,
170 }
171}
172
173/**
174 * Las decisiones de la memoria (esta sesión, también las que siguen corriendo) y las del store,
175 * sin repetir las de esta sesión que ya se guardaron, de la más nueva a la más vieja. Con `n`,
176 * solo las `n` más nuevas.
177 */
178export function ultimasDecisiones(
179 subs: readonly Subagente[],
180 sesion: string | null,
181 guardados: readonly Registro[],
182 n = Infinity,
183): FilaDecision[] {
184 const deMemoria = subs.map(s => filaDeSub(s, sesion))
185 // Sin id de sesión, la memoria no sabe en qué clave del store quedó lo suyo: se compara sin la
186 // sesión, así un registro ya guardado no aparece dos veces.
187 const clave = sesion === null
188 ? (r: Parameters<typeof claveRegistro>[0]) => claveRegistro({ ...r, sesion: null })
189 : claveRegistro
190 const vistos = new Set(deMemoria.map(clave))
191 const delStore = guardados.filter(r => !vistos.has(clave(r))).map(filaDeRegistro)
192 return [...deMemoria, ...delStore].sort((a, b) => b.inicio - a.inicio).slice(0, n)
193}
194
195/** Una cuenta por etiqueta, de la más frecuente a la menos; empatadas, por orden de aparición. */
196export type Cuenta = [string, number][]
197
198function contar(etiquetas: readonly string[]): Cuenta {
199 const m = new Map<string, number>()
200 for (const e of etiquetas) m.set(e, (m.get(e) ?? 0) + 1)
201 return [...m].sort((a, b) => b[1] - a[1])
202}
203
204export type ResumenDecisiones = {
205 total: number
206 reasignadas: number
207 /** Por modelo con el que corrió; `?` si no se sabe. */
208 porModelo: Cuenta
209 /** Por modo de la política; `sin dato` en los registros viejos. */
210 porModo: Cuenta
211 /** Por tipo (`sin tipo` si no hay), cada uno con su cuenta por modelo; los tipos, del más frecuente al menos. */
212 porTipo: [string, Cuenta][]
213}
214
215export function resumenDecisiones(filas: readonly FilaDecision[]): ResumenDecisiones {
216 const tipos = new Map<string, string[]>()
217 for (const f of filas) {
218 const t = f.tipo ?? 'sin tipo'
219 tipos.set(t, [...(tipos.get(t) ?? []), f.modelo ?? '?'])
220 }
221 return {
222 total: filas.length,
223 reasignadas: filas.filter(f => f.reasignado).length,
224 porModelo: contar(filas.map(f => f.modelo ?? '?')),
225 porModo: contar(filas.map(f => f.modo ?? 'sin dato')),
226 porTipo: [...tipos]
227 .sort((a, b) => b[1].length - a[1].length)
228 .map(([t, modelos]): [string, Cuenta] => [t, contar(modelos)]),
229 }
230}
231
232/**
233 * Los de los últimos `dias` días: el rango es `(ahora - dias, ahora]`, con el borde de abajo
234 * exclusivo, así la semana anterior (la misma llamada con `ahora - 7d`) no repite ninguno. Se
235 * decide por grupo `sesion·tarea` según el último `fin` del grupo, que entra o queda afuera
236 * entero: una tarea no se parte en el borde. Los registros sin tarea van por su propio `fin`.
237 */
238export function ultimosDias(lista: readonly Registro[], ahora: number, dias: number): Registro[] {
239 const desde = ahora - dias * 86_400_000
240 const grupo = (r: Registro) => (r.tarea === null ? null : `${r.sesion}·${r.tarea}`)
241 const ultimoFin = new Map<string, number>()
242 for (const r of lista) {
243 const g = grupo(r)
244 if (g !== null) ultimoFin.set(g, Math.max(ultimoFin.get(g) ?? -Infinity, r.fin))
245 }
246 return lista.filter(r => {
247 const g = grupo(r)
248 const fin = g === null ? r.fin : ultimoFin.get(g)!
249 return fin > desde && fin <= ahora
250 })
251}
252
253/** Reutiliza `metricasDe`; la tarea lleva la sesión adelante para no mezclar "Tarea 1" de sesiones distintas. */
254export function metricasHistorial(lista: readonly Registro[]): MetricasSesion {
255 const subs: Subagente[] = lista.map((r, i) => ({
256 agentId: String(i),
257 descripcion: '',
258 tarea: r.tarea === null ? null : `${r.sesion}·${r.tarea}`,
259 tipo: r.tipo,
260 pedido: r.pedido,
261 elegido: r.pedido,
262 escalon: r.modelo ? { modelo: r.modelo } : null,
263 motivos: [],
264 reasignado: r.reasignado,
265 inicio: r.inicio,
266 fin: r.fin,
267 }))
268 return metricasDe(subs, 0)
269}
270
271/** El historial de una sesión, como lo guarda el store. */
272/** `registros: null` marca una lectura fallida: no se sabe qué tiene, así que no se poda. */
273export type HistorialSesion = { clave: string; registros: readonly Registro[] | null }
274
275/**
276 * Las claves a borrar con `$.store.delete`, nunca `actual` (la de esta sesión):
277 * - nunca una sesión con `registros: null` (lectura fallida), que tampoco cuenta en el total;
278 * - las sesiones cuyo último `fin` tiene `dias` días o más (o que no tienen registros válidos);
279 * - si con las que quedan el total pasa de `max`, las más viejas (por último `fin`) hasta entrar.
280 */
281export function clavesAPodar(
282 sesiones: readonly HistorialSesion[],
283 ahora: number,
284 actual: string,
285 max = MAX_HISTORIAL,
286 dias = DIAS_HISTORIAL,
287): string[] {
288 const limite = ahora - dias * 86_400_000
289 const conFin = sesiones
290 .filter(s => s.clave !== actual)
291 .flatMap(s => (s.registros === null ? [] : [{ clave: s.clave, registros: s.registros }]))
292 .map(s => ({ ...s, ultimo: Math.max(-Infinity, ...s.registros.map(r => r.fin)) }))
293 const borrar = conFin.filter(s => s.ultimo <= limite).map(s => s.clave)
294 let total = sesiones.filter(s => !borrar.includes(s.clave)).reduce((a, s) => a + (s.registros?.length ?? 0), 0)
295 const quedan = conFin.filter(s => !borrar.includes(s.clave)).sort((a, b) => a.ultimo - b.ultimo)
296 for (const s of quedan) {
297 if (total <= max) break
298 borrar.push(s.clave)
299 total -= s.registros.length
300 }
301 return borrar
302}
303src/historialMarkdown.ts 101 lines1import type { Cuenta, Registro } from './historial'
2import { filaDeRegistro, resumenDecisiones, ultimosDias } from './historial'
3
4// Puro: arma el texto del export de `/router history`. Leer el store y escribir el archivo lo
5// hace hooks/register.tsx.
6
7const dos = (n: number) => String(n).padStart(2, '0')
8
9/** `YYYY-MM-DD` en la hora local. */
10export function fechaLocal(ms: number): string {
11 const d = new Date(ms)
12 return `${d.getFullYear()}-${dos(d.getMonth() + 1)}-${dos(d.getDate())}`
13}
14
15/** `HH:MM` en la hora local. */
16export function horaLocal(ms: number): string {
17 const d = new Date(ms)
18 return `${dos(d.getHours())}:${dos(d.getMinutes())}`
19}
20
21const momento = (ms: number) => `${fechaLocal(ms)} ${horaLocal(ms)}`
22
23/** `opus 3 · sonnet 2`. */
24export const textoCuenta = (c: Cuenta) => c.map(([k, n]) => `${k} ${n}`).join(' · ')
25
26/**
27 * Una celda de tabla: sin saltos de línea, con la `\` escapada primero y después el `|`, así un
28 * `a\|b` no deja un `|` suelto que abra otra columna.
29 */
30const celda = (s: string) => s.replace(/\r?\n/g, ' ').replace(/\\/g, '\\\\').replace(/\|/g, '\\|')
31
32/** Agrupa conservando el orden en que aparece cada clave. */
33function agrupar<T>(xs: readonly T[], clave: (x: T) => string): [string, T[]][] {
34 const m = new Map<string, T[]>()
35 for (const x of xs) m.set(clave(x), [...(m.get(clave(x)) ?? []), x])
36 return [...m]
37}
38
39function seccionTarea(titulo: string, registros: readonly Registro[]): string[] {
40 const rondas = registros.filter(r => r.tipo === 're-review').length
41 const ciclo = registros.map(r => r.tipo ?? '?').join(' → ')
42 const filas = registros.map(r => {
43 const f = filaDeRegistro(r)
44 const celdas = [horaLocal(f.inicio), f.tipo ?? '—', f.descripcion, `${f.pedido} → ${f.elegido}`, f.modo ?? '—', f.motivos.join(' · ')]
45 return `| ${celdas.map(celda).join(' | ')} |`
46 })
47 return [
48 `### ${titulo}`,
49 '',
50 `Ciclo: ${ciclo} · ${rondas} ${rondas === 1 ? 'ronda' : 'rondas'} de re-review`,
51 '',
52 '| Hora | Tipo | Descripción | Pedido → elegido | Modo | Motivos |',
53 '|---|---|---|---|---|---|',
54 ...filas,
55 '',
56 ]
57}
58
59/**
60 * El Markdown de `/router history [days]`: un encabezado con la fecha, el rango y el resumen, y
61 * una sección por sesión con una tabla por tarea (con su ciclo y sus rondas de re-review). El rango
62 * es el de `ultimosDias`: `(ahora - dias, ahora]`, y una tarea entra o queda afuera entera.
63 */
64export function historialMarkdown(registros: readonly Registro[], ahora: number, dias: number): string {
65 const enRango = ultimosDias(registros, ahora, dias)
66 const r = resumenDecisiones(enRango.map(filaDeRegistro))
67 const rango = dias === 1 ? 'último día' : `últimos ${dias} días`
68 const lineas = [
69 '# Historial del router',
70 '',
71 `Exportado el ${momento(ahora)} · ${rango} (${momento(ahora - dias * 86_400_000)} → ${momento(ahora)})`,
72 '',
73 '## Resumen',
74 '',
75 `- ${r.total} ${r.total === 1 ? 'decisión' : 'decisiones'}, ${r.reasignadas} ${r.reasignadas === 1 ? 'reasignada' : 'reasignadas'}`,
76 ]
77 if (r.total > 0) {
78 lineas.push(
79 `- Por modelo: ${textoCuenta(r.porModelo)}`,
80 `- Por tipo → modelo: ${r.porTipo.map(([t, c]) => `${t} → ${textoCuenta(c)}`).join(' · ')}`,
81 `- Modo: ${textoCuenta(r.porModo)}`,
82 )
83 }
84 lineas.push('')
85 if (r.total === 0) {
86 lineas.push('Sin decisiones en el rango.', '')
87 return lineas.join('\n')
88 }
89
90 const ordenados = [...enRango].sort((a, b) => a.inicio - b.inicio)
91 for (const [sesion, deSesion] of agrupar(ordenados, x => x.sesion)) {
92 lineas.push(`## Sesión ${sesion.slice(0, 8)} · ${fechaLocal(deSesion[0]!.inicio)}`, '')
93 const tareas = agrupar(deSesion, x => x.tarea ?? '')
94 // Las tareas en orden de inicio; los subagentes sin tarea, al final.
95 for (const [tarea, deTarea] of [...tareas.filter(([t]) => t !== ''), ...tareas.filter(([t]) => t === '')]) {
96 lineas.push(...seccionTarea(tarea || 'Sin tarea', deTarea))
97 }
98 }
99 return lineas.join('\n')
100}
101src/overrides.ts 186 lines1import type { Escalon, Modelo, Overrides, Tipo } from '../types'
2import { formatEscalon, MODELOS, parseEscalon, TIPOS_FINALES } from './taxonomia'
3
4export const OVERRIDES_INICIAL: Overrides = { activo: true, permitidos: null, fijados: {} }
5
6export type Comando =
7 | { accion: 'panel' }
8 | { accion: 'on' }
9 | { accion: 'off' }
10 | { accion: 'reset' }
11 | { accion: 'permitir'; modelos: Modelo[] }
12 | { accion: 'fijar'; tipo: Tipo; escalon: Escalon }
13 | { accion: 'banco'; max: number | null; solo: 'haiku' | 'jev' | null }
14 | { accion: 'historial'; dias: number }
15 | { accion: 'hot'; valor: boolean | null }
16 | { accion: 'error'; mensaje: string }
17
18export const AYUDA =
19 'Usage: /router · /router on|off|reset · /router hot on|off · /router allow sonnet,opus · /router set <type> <model>·<effort> · /router history [days] · /router bench'
20
21const USO_BANCO = 'Usage: /router bench [--max N] [--solo haiku|jev]'
22
23function parsearBanco(resto: readonly string[]): Comando {
24 let max: number | null = null
25 let solo: 'haiku' | 'jev' | null = null
26 for (let i = 0; i < resto.length; i += 2) {
27 const opcion = resto[i]!
28 const valor = resto[i + 1]
29 if (opcion.toLowerCase() === '--max') {
30 if (valor === undefined || !/^\d+$/.test(valor) || Number(valor) < 1) {
31 return { accion: 'error', mensaje: `--max pide un número de casos mayor que 0. ${USO_BANCO}` }
32 }
33 max = Number(valor)
34 } else if (opcion.toLowerCase() === '--solo') {
35 const v = valor?.toLowerCase()
36 if (v !== 'haiku' && v !== 'jev') {
37 return { accion: 'error', mensaje: `--solo es haiku o jev, no "${valor ?? ''}". ${USO_BANCO}` }
38 }
39 solo = v
40 } else {
41 return { accion: 'error', mensaje: `Opción desconocida: ${opcion}. ${USO_BANCO}` }
42 }
43 }
44 return { accion: 'banco', max, solo }
45}
46
47/** Días del export de `/router history` cuando no se dicen. */
48export const DIAS_EXPORT = 7
49
50function parsearHistorial(resto: readonly string[]): Comando {
51 if (resto.length === 0) return { accion: 'historial', dias: DIAS_EXPORT }
52 const [dias] = resto
53 if (resto.length > 1 || !/^\d+$/.test(dias!) || Number(dias) < 1) {
54 return { accion: 'error', mensaje: `Usage: /router history [days], con días mayor que 0 (${DIAS_EXPORT} si no lo decís)` }
55 }
56 return { accion: 'historial', dias: Number(dias) }
57}
58
59export function parsearComando(args: string): Comando {
60 const [verbo = '', ...resto] = args.trim().split(/\s+/).filter(Boolean)
61 switch (verbo.toLowerCase()) {
62 case '':
63 case 'panel':
64 return { accion: 'panel' }
65 case 'on':
66 return { accion: 'on' }
67 case 'off':
68 return { accion: 'off' }
69 case 'reset':
70 return { accion: 'reset' }
71 case 'allow':
72 case 'permitir': {
73 const modelos = resto.join(',').split(',').map(s => s.trim().toLowerCase()).filter(Boolean)
74 const malos = modelos.filter(m => !(MODELOS as readonly string[]).includes(m))
75 if (modelos.length === 0) {
76 return { accion: 'error', mensaje: 'Decime qué modelos: /router allow sonnet,opus' }
77 }
78 if (malos.length > 0) {
79 return { accion: 'error', mensaje: `Modelos no válidos: ${malos.join(', ')}. ${AYUDA}` }
80 }
81 return { accion: 'permitir', modelos: [...new Set(modelos)] as Modelo[] }
82 }
83 case 'set':
84 case 'fijar': {
85 const [tipo, ...escalonTexto] = resto
86 const validos: readonly string[] = [...TIPOS_FINALES, 'review']
87 if (tipo === undefined || !validos.includes(tipo.toLowerCase())) {
88 return { accion: 'error', mensaje: `Tipo no válido: ${tipo ?? '(vacío)'}. Tipos: ${validos.join(', ')}` }
89 }
90 const escalon = parseEscalon(escalonTexto.join(' '))
91 if (!escalon) {
92 return {
93 accion: 'error',
94 mensaje: `Escalón no válido: "${escalonTexto.join(' ')}". Ejemplos: opus·xhigh, sonnet·medium, haiku·high`,
95 }
96 }
97 return { accion: 'fijar', tipo: tipo.toLowerCase() as Tipo, escalon }
98 }
99 case 'hot': {
100 const [valor, ...sobra] = resto.map(s => s.toLowerCase())
101 if (sobra.length > 0 || (valor !== undefined && valor !== 'on' && valor !== 'off')) {
102 return { accion: 'error', mensaje: 'Uso: /router hot on|off (sin nada, dice cómo está)' }
103 }
104 return { accion: 'hot', valor: valor === undefined ? null : valor === 'on' }
105 }
106 case 'bench':
107 case 'banco':
108 return parsearBanco(resto)
109 case 'history':
110 case 'historial':
111 return parsearHistorial(resto)
112 default:
113 return { accion: 'error', mensaje: `No entiendo "${verbo}". ${AYUDA}` }
114 }
115}
116
117export function aplicarComando(o: Overrides, c: Comando): Overrides {
118 switch (c.accion) {
119 case 'on':
120 return { ...o, activo: true }
121 case 'off':
122 return { ...o, activo: false }
123 case 'reset':
124 return OVERRIDES_INICIAL
125 case 'permitir':
126 return { ...o, permitidos: c.modelos }
127 case 'fijar':
128 // `set review` (legado) fija las dos tallas.
129 if (c.tipo === 'review') {
130 return { ...o, fijados: { ...o.fijados, 'review-large': c.escalon, 'review-small': c.escalon } }
131 }
132 return { ...o, fijados: { ...o.fijados, [c.tipo]: c.escalon } }
133 default:
134 return o
135 }
136}
137
138export function describirComando(c: Comando): string {
139 switch (c.accion) {
140 case 'on':
141 return 'Router: prendido.'
142 case 'off':
143 return 'Router: apagado. Los subagentes salen con lo que pide Claude.'
144 case 'reset':
145 return 'Router: sin overrides en esta sesión.'
146 case 'permitir':
147 return `Router: en esta sesión solo se usan ${c.modelos.join(', ')}.`
148 case 'fijar':
149 if (c.tipo === 'review') {
150 return `Router: review-large y review-small quedan fijados en ${formatEscalon(c.escalon)} para esta sesión.`
151 }
152 return `Router: ${c.tipo} queda fijado en ${formatEscalon(c.escalon)} para esta sesión.`
153 case 'error':
154 return c.mensaje
155 case 'panel':
156 return 'Panel del router abierto.'
157 case 'banco':
158 return 'Router: corriendo el banco de prueba.'
159 case 'hot':
160 if (c.valor === true) {
161 return 'Router: modo hot prendido, también para las próximas sesiones. Cerca del reset de la ventana de 5 h, con uso de sobra, los subagentes suben uno o dos escalones.'
162 }
163 if (c.valor === false) return 'Router: modo hot apagado, también para las próximas sesiones. Cada tipo de tarea corre en su modelo base.'
164 return 'Uso: /router hot on|off'
165 case 'historial':
166 return `Router: exportando el historial de ${c.dias === 1 ? 'el último día' : `los últimos ${c.dias} días`}.`
167 }
168}
169
170export function tienePin(descripcion: string): boolean {
171 return /\[pin\]/i.test(descripcion)
172}
173
174/**
175 * La sección `router:pin` del system prompt: le saca a Claude la elección del modelo y le pide usar
176 * ese razonamiento en descomponer y paralelizar sin conflictos. Cómo nombrar los subagentes va en
177 * la descripción de la tool Agent (CONVENCION_AGENT), donde Claude lo lee al usarla.
178 */
179export const LINEA_ROUTER =
180 'Router de subagentes activo: el modelo y el esfuerzo de cada subagente los elige un router ' +
181 'según el tipo de tarea y según cuánto queda de la ventana de 5 horas. No gastes razonamiento ' +
182 'eligiendo `model` para los subagentes. Usá ese tiempo en descomponer bien el trabajo: lanzá en ' +
183 'paralelo las tareas independientes y serializá las que tocan los mismos archivos, comparten ' +
184 'estado o dependen del resultado de otra. Cómo nombrar cada subagente está en la descripción ' +
185 'de la tool Agent.'
186