SLOPSHOPPER

agent-router

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

newpaneguardcommandtoaststatus
v0.2.0MITupdated 2026-10-15CiroVilmer/jev-agent-router/router
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · agent-router
│ ┃ Router ✕ › fix the failing auth test and add an audit log call │ ┃ [ ● En curso ] [ Métricas vs tu base ] [ H │ ┃ ⏺ Read(src/auth.ts) │ ┃ VENTANA ⎿ Read 6 lines │ ┃ ⚪ modo hot apagado (/router hot on) · sin ⏺ Update(src/auth.ts) │ ┃ datos de uso ⎿ Added 2 lines, removed 1 line │ ┃ ⏺ Bash(bun test) │ ┃ EN CURSO ⎿ 3 pass, 1 fail │ ┃ Sin subagentes corriendo. │ ┃ ● Done. refresh now rejects expired claims and logs an audit event. │ ┃ POR TAREA │ ┃ Ningún subagente nombró una tarea todavía. ✻ Worked for 42s · done 4:20 PM │ │ › /router │ ⎿ agent-router: Panel del router abierto. │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Router
[ ● En curso ] [ Métricas vs tu base ] [ Historial ] VENTANA ⚪ modo hot apagado (/router hot on) · sin datos de uso EN CURSO Sin subagentes corriendo. POR TAREA Ningún subagente nombró una tarea todavía.
README

router

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.

Cargarlo

  • Una sesión: claude --plugin-dir <repo>/router
  • Siempre: claude plugin marketplace add CiroVilmer/jev-agent-router y después claude plugin install agent-router@jev-agent-router

Usarlo

ComandoQué hace
/routerAbre el panel: pestañas En curso, Métricas vs tu base e Historial (teclas 1, 2 y 3)
/router off / /router onApaga o prende el router en esta sesión
/router allow sonnet,opusSolo usa esos modelos en esta sesión
/router set review opus·maxFija un tipo por encima de la política. review fija las dos tallas; review-large y review-small, cada una por separado
/router resetBorra los overrides de la sesión
/router hot on · /router hot offPrende o apaga el modo hot (se guarda para las próximas sesiones; apagado por defecto). /router hot dice cómo está
/router benchCorre 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 subagenteRespeta 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.

Lo que ve Claude

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.

Clasificadores (fase 2)

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:

CampoDefaultQué hace
clasificadorreglasQuién decide: reglas (el respaldo, sin modelo), haiku o jev
sombraoffCorre 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
jevUmbral0.7En 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)
topeMs1000Si 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.

Métricas (fase 3)

/router → pestaña Métricas vs tu base compara tres columnas:

ColumnaDe dónde sale
sesiónLos subagentes de esta sesión, en memoria
7 díasEl historial guardado de todas las sesiones, en el rango (ahora − 7 días, ahora]
baseLa línea base de antes del router (router/src/lineaBase.ts)

Qué mide cada fila:

  • reviews en Opus: de los subagentes 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.
  • rondas/tarea: promedio de 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.
  • min/tarea: la mediana (no el promedio) de los minutos de cada tarea terminada, desde el primer spawn hasta que termina su último subagente.

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.

  • Para cada ventana de 5 h cerrada, lo sin usar es 100 − el % más alto que se vio.
  • Cobertura: una ventana cuenta en el promedio solo si se vio en los 60 minutos antes de su reset. Si no, figura como sin cobertura: el máximo que se vio puede no ser el final, así que no se promedia.
  • El promedio está sesgado hacia abajo: las ventanas donde dejaste de trabajar más de 1 h antes del reset (las que suelen quedar con más sin usar) no tienen cobertura y no entran.
  • La ventana de 5 h no tiene base previa al router: los transcripts no traen el %. Por eso se compara contra la semana anterior, (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.

ClaveQué guardaTope
historial:<sessionId>Un registro por subagente terminado de esa sesión (unos 450 B con la decisión). Cada sesión escribe solo la suya3000 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
ventanasUna entrada por ventana de 5 h: el % más alto visto y cuándo se vio por primera y última vezLas 300 más recientes
sombraLas muestras del modo sombraLas ú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.

Historial de decisiones

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.

Talla de las reviews (fase 7)

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:

  1. Un rango de tareas. Si la description nombra un rango ("Tasks 3-5"), es review-large.
  2. El build de la misma tarea. Si hubo un build de esa tarea en la sesión, la review hereda su talla (build-large → review-large, build-small → review-small).
  3. Las palabras de alcance. Si la description no nombra una tarea numerada, bloque/block, feature, fase/phase, integración/integration, rama/branch o merge la hacen review-large.
  4. El clasificador, con 2 etiquetas. Si duda, falla o no hay clasificador, es 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).

Tipos y base

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

Source 24 files
hooks/register.tsx 632 lines
1import { 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}
632
src/banco.ts 80 lines
1import 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}
80
src/clasificadores/index.ts 45 lines
1import 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}
45
src/clasificadores/haiku.ts 20 lines
1import 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}
20
src/clasificadores/jev.ts 50 lines
1import 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}
50
src/clasificadores/sombra.ts 152 lines
1import 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}
152
src/clasificadores/tope.ts 20 lines
1import 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}
20
src/convencion.ts 32 lines
1import 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}
32
src/decidir.ts 128 lines
1import 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}
128
src/historial.ts 303 lines
1import 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}
303
src/historialMarkdown.ts 101 lines
1import 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}
101
src/overrides.ts 186 lines
1import 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