@thebattlearena/corner 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/README.md +38 -5
  2. package/fonts/battle-arena-icons/BattleArenaIcons.ttf +0 -0
  3. package/package.json +1 -1
  4. package/plugin/.claude-plugin/plugin.json +1 -1
  5. package/plugin/hooks/icons.generated.ts +2 -0
  6. package/plugin/hooks/pane.tsx +16 -2
  7. package/plugin/hooks/register.tsx +62 -33
  8. package/plugin/hooks/ring-io.ts +59 -1
  9. package/plugin/hooks/ring-pane.tsx +125 -35
  10. package/plugin/hooks/ring.ts +73 -38
  11. package/plugin/hooks/sparring.ts +139 -0
  12. package/plugin/icons/rudo-amber-2x1.png +0 -0
  13. package/plugin/icons/rudo-amber-4x2.png +0 -0
  14. package/plugin/icons/rudo-amber-5x2.png +0 -0
  15. package/plugin/icons/rudo-amber-6x2.png +0 -0
  16. package/plugin/icons/rudo-grey-2x1.png +0 -0
  17. package/plugin/icons/rudo-grey-4x2.png +0 -0
  18. package/plugin/icons/rudo-grey-5x2.png +0 -0
  19. package/plugin/icons/rudo-grey-6x2.png +0 -0
  20. package/plugin/icons/rudo-pink-2x1.png +0 -0
  21. package/plugin/icons/rudo-pink-4x2.png +0 -0
  22. package/plugin/icons/rudo-pink-5x2.png +0 -0
  23. package/plugin/icons/rudo-pink-6x2.png +0 -0
  24. package/plugin/icons/rudo-pink-dim-2x1.png +0 -0
  25. package/plugin/icons/rudo-pink-dim-4x2.png +0 -0
  26. package/plugin/icons/rudo-pink-dim-5x2.png +0 -0
  27. package/plugin/icons/rudo-pink-dim-6x2.png +0 -0
  28. package/plugin/icons/rudo-red-2x1.png +0 -0
  29. package/plugin/icons/rudo-red-4x2.png +0 -0
  30. package/plugin/icons/rudo-red-5x2.png +0 -0
  31. package/plugin/icons/rudo-red-6x2.png +0 -0
  32. package/plugin/tests/ring-pane.test.tsx +154 -0
  33. package/plugin/tests/ring.test.ts +13 -4
  34. package/plugin/tests/sparring.test.ts +204 -0
  35. package/plugin/types/index.d.ts +25 -1
  36. package/ring/.claude-plugin/plugin.json +1 -1
  37. package/ring/agents/rudo.md +95 -0
  38. package/ring/scripts/chronicle.mjs +24 -0
  39. package/ring/scripts/config.mjs +47 -1
  40. package/ring/scripts/fight.mjs +238 -0
  41. package/ring/scripts/judges-card.mjs +53 -9
  42. package/ring/scripts/live-hook.mjs +42 -15
  43. package/ring/scripts/live.mjs +96 -1
  44. package/ring/scripts/mask-guard.mjs +184 -6
  45. package/ring/scripts/monitors.mjs +2 -1
  46. package/ring/scripts/play-by-play.mjs +39 -9
  47. package/ring/scripts/round-bell.mjs +10 -6
  48. package/ring/scripts/sparring-chronicle.mjs +470 -0
  49. package/ring/scripts/sparring.mjs +1161 -0
  50. package/ring/skills/play-by-play/SKILL.md +49 -3
  51. package/ring/skills/play-by-play/templates/chronicle.html +7 -0
  52. package/ring/skills/referee/SKILL.md +46 -2
  53. package/ring/skills/sparring/SKILL.md +408 -0
  54. package/ring/skills/sparring/references/host-signal.md +86 -0
  55. package/ring/skills/sparring/references/lessons.md +50 -0
  56. package/ring/skills/sparring/templates/pr-body.md +34 -0
  57. package/ring/skills/sparring/templates/rudo-brief.md +47 -0
  58. package/ring/skills/sparring/templates/sparring-card.md +33 -0
  59. package/ring/skills/weigh-in/SKILL.md +21 -3
package/README.md CHANGED
@@ -351,10 +351,25 @@ Se instala **junto con `battlearena`**, con el mismo comando y del mismo marketp
351
351
  | `/ring:referee close` | Gates finales, `validateCommand` opcional sobre la punta exacta, cuerpo del PR |
352
352
  | `/ring:no-surrender` | Modo noche: fija el `/goal` y deja lo que es del humano anotado, sin bloquearse |
353
353
  | `/ring:title-defense <pr> [--max-rounds N]` | Defiende el PR ante la review de Copilot: cataloga cada hallazgo, lo corrige por el mismo ciclo y re-pide review hasta quedar limpio |
354
+ | `/ring:sparring <idea>` | Borrador rápido y feo de una funcionalidad sólo para probar si vale: PR en draft que el CI del proyecto puede desplegar |
354
355
  | `/ring:play-by-play <pr>` | Narra el combate como crónica deportiva con la hora real de cada golpe y la publica como página privada, siempre en el mismo link |
355
356
 
356
- Agentes: `ring:wrestler` (ejecutor, `effort: medium`) y `ring:cornerman` (revisor de sólo lectura,
357
- `effort: high`, en modo rango, concepto o lote).
357
+ Agentes: `ring:wrestler` (ejecutor, `effort: medium`), `ring:cornerman` (revisor de sólo lectura,
358
+ `effort: high`, en modo rango, concepto o lote) y `ring:rudo` (el del sparring, `effort: medium`).
359
+
360
+ **Sparring** (`/ring:sparring`): un rudo por repo escribe en su worktree, dentro de un timebox de
361
+ 3 h como máximo, lo mínimo para que el juez pruebe una hipótesis de negocio. Un solo sparring
362
+ puede abarcar back y front: una hipótesis, una ficha (`~/.claude/ring/sparrings/<slug>/`) y un
363
+ veredicto, con un worktree, una rama y un PR por repo, y lo que uno le entrega al otro (un SDK con
364
+ `npm pack`) en su carpeta `tools/`; se suspenden las reglas de
365
+ estilo y de tests, nunca las de operación en vivo (F), secretos ni base de datos (sólo aditiva,
366
+ nunca contra producción). El PR en draft desde `sparring/<slug>` con las etiquetas `sparring` y
367
+ `do-not-merge` es el evento: el CI del proyecto lo atrapa y despliega su preview si quiere, y el
368
+ ring la lee de la API de Deployments de GitHub (`sparring.previewEnvironment`). Las credenciales
369
+ viajan sólo por nombre en un bloque `<!-- sparring:credentials … -->` y el proyecto las resuelve
370
+ de su almacén de secretos; se asumen rotadas al terminar. El juez da el veredicto (KO, revancha o
371
+ descartado); con KO, `/ring:referee start <pr> --from-sparring <slug>` refactoriza esa misma
372
+ rama. El contrato para el proyecto está en `ring/skills/sparring/references/host-signal.md`.
358
373
 
359
374
  **Config del repo** (`.claude/ring.json`, opcional): `gates` (typecheck, tests por ruta con
360
375
  `{paths}`, lint), `validateCommand`, `rulebook` (por defecto el genérico del plugin),
@@ -376,6 +391,13 @@ rebotes, los commits, las revisiones de Copilot, los eventos del PR y las corrid
376
391
  inventa una hora: lo que no tiene registro se dice como hueco. Es privada; tú la compartes desde su
377
392
  menú. Más adelante `ring` podrá mandar el link de la crónica al dashboard de Battle Arena; hoy no se
378
393
  conecta al colector.
394
+ Cuando la pelea empieza en un sparring, la crónica arranca ahí (`/ring:play-by-play --sparring
395
+ <slug>`) y la cuenta a dos de tres caídas: el rudo contra el juez, la defensa de los técnicos y el
396
+ árbitro (cada combate abierto `--from-sparring`). Es una sola página con un solo link, guardado en
397
+ `sparring.json`; `sparring.mjs mark` fecha el vestidor, la defensa, la campana, la máscara y «sin
398
+ equipo» (`no-team`: no hay segunda caída). Tras el KO, `sparring.mjs vestidor <slug>` corre en cada
399
+ repo sus gates propios (typecheck, lint y `build`, que `config.mjs` detecta de `scripts.build`) y
400
+ sólo con todos en verde fecha el vestidor.
379
401
 
380
402
  **El contrato es del juez**: si un concepto agrega o cambia un endpoint del spec de la API
381
403
  (`apiSpec`) o un modelo de base de datos (`dbModels`: entidades, schema, migraciones), el trabajo se
@@ -423,7 +445,16 @@ feed en vivo o, en combates de antes, de `gh pr list --head ring/<pr>/round-<n>`
423
445
  round). Un escritor puede mandar la tabla de la ficha con `fields` (`live.mjs emit --field
424
446
  rama=feat/x`).
425
447
 
426
- **La fuente de íconos.** `fonts/battle-arena-icons/BattleArenaIcons.ttf`: 58 glifos en el área de
448
+ **Los sparrings en Ringside.** Cada sparring activo (sin veredicto `ko` ni `discard` y tocado en las
449
+ últimas 12 h) lleva su propia sección «▸ Sparring <slug>» tras la del combate, con su tira de
450
+ árbitro, rudo (la máscara, en rosa) y juez: el rudo se enciende mientras escribe, el juez cuando hay
451
+ una pregunta abierta. Desplegada, su cabecera dice la ronda (veredictos de `history` + 1), el último
452
+ veredicto y cada repo con su PR. Salen los que registró la sesión (`sparring.mjs` registra a la que
453
+ lo corre, salvo `list`) o, si no registró ninguno, los que trabajan en su carpeta; el feed vive en
454
+ `~/.claude/ring/sparrings/<slug>/live.jsonl` y lo escriben `sparring.mjs` y `live-hook.mjs` (Agent
455
+ `ring:rudo` o «Rudo: …», AskUserQuestion) cuando la sesión no tiene combate propio.
456
+
457
+ **La fuente de íconos.** `fonts/battle-arena-icons/BattleArenaIcons.ttf`: 59 glifos en el área de
427
458
  uso privado suplementaria A (U+FBA00–U+FBA85, lejos de los de Nerd Fonts), con nombre de glifo igual
428
459
  al id de `src/codepoints.json`. UPM 1000; un ícono de una celda avanza 600 y uno de dos, 1200 (la
429
460
  celda de una mono típica es 0.6 em), centrados en vertical en y = 340. 31 vienen de Lucide (ISC,
@@ -476,7 +507,7 @@ ejemplo, el de agents-studio-be (su batería de campañas, que además abre la v
476
507
 
477
508
  - `id` (minúsculas, dígitos, `_`, `-`; nunca el de un actor del ring) y `label`. Una entrada
478
509
  inválida se ignora con un aviso en `config.mjs show`; nunca tumba `mask-guard`.
479
- - `icon` (opcional): cualquiera de los 58 nombres de la fuente Battle Arena Icons; uno que no exista,
510
+ - `icon` (opcional): cualquiera de los 59 nombres de la fuente Battle Arena Icons; uno que no exista,
480
511
  o ninguno, deja las dos primeras letras del `label` (los de puntos, acciones y medios son de la
481
512
  interfaz de Ringside y no tienen imagen, sólo glifo):
482
513
  - marca: `arena`
@@ -540,7 +571,9 @@ ejemplo, el de agents-studio-be (su batería de campañas, que además abre la v
540
571
 
541
572
  **`mask-guard`** (hook `PreToolUse` sobre Bash) bloquea **siempre** `git add -A/./--all`,
542
573
  `git stash pop`, `git reset --hard` con el árbol sucio y, si `forbidAiTrailers` está activo, los
543
- commits con trailers de atribución de IA (los coautores humanos pasan). **Sólo mientras hay un
574
+ commits con trailers de atribución de IA (los coautores humanos pasan); y en un sparring, commitear
575
+ `.env.sparring`, mezclar una rama `sparring/*` o empujarla a otra rama, abrir su PR sin draft o sin
576
+ `do-not-merge`, y sacarlo de draft o mezclarlo. **Sólo mientras hay un
544
577
  combate activo en ese repo** bloquea además lo que decide el humano: merges (y `git pull` que no sea
545
578
  `--ff-only`/`--rebase`), cambios de base, cerrar o sacar de draft PRs, `gh pr review`, pedir review
546
579
  a Copilot, force push, push a una rama protegida, comandos pesados fuera de `one-at-a-time` y
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@thebattlearena/corner",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Corner: tu esquina de Battle Arena en Claude Code (el plugin `battlearena`). Hooks de tracking, objetivo del turno, plan de batalla /battlearena:conquer, Ringside (el panel de tu día), el ring y la radio. Zero-dependency.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "battlearena",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Corner, tu esquina de Battle Arena en Claude Code: tus sesiones al colector, el objetivo del turno, el plan de batalla /battlearena:conquer y Ringside, el panel de tu día junto al chat.",
5
5
  "author": {
6
6
  "name": "Battle Arena"
@@ -11,6 +11,7 @@ export const FONT_ICONS = {
11
11
  'campana': { codepoint: 0xfba13, cells: 1, group: 'actores', tints: ['amber','red'], png: true },
12
12
  'copilot': { codepoint: 0xfba14, cells: 1, group: 'actores', tints: ['purple','red','amber'], png: true },
13
13
  'juez': { codepoint: 0xfba15, cells: 1, group: 'actores', tints: ['orange','red','amber'], png: true },
14
+ 'rudo': { codepoint: 0xfba16, cells: 1, group: 'actores', tints: ['pink','red','amber'], png: true },
14
15
  'activo': { codepoint: 0xfba20, cells: 1, group: 'estados', tints: [], png: true },
15
16
  'en-la-esquina': { codepoint: 0xfba21, cells: 1, group: 'estados', tints: [], png: true },
16
17
  'golpe': { codepoint: 0xfba22, cells: 1, group: 'estados', tints: [], png: true },
@@ -98,6 +99,7 @@ export const ACTOR_COLOR = {
98
99
  campana: 'amber',
99
100
  copilot: 'purple',
100
101
  juez: 'orange',
102
+ rudo: 'pink',
101
103
  } as const;
102
104
 
103
105
  /** Las cajas de ícono con PNG (columnas × filas). */
@@ -50,8 +50,8 @@ import {
50
50
  unlinkedMeetingMinutes,
51
51
  } from './meetings';
52
52
  import { outsideMinutes, planMinutes, workedMinutes } from './close';
53
- import type { RingPaneModel } from './ring-pane';
54
- import { pointBadge, ringHost, RingRow } from './ring-pane';
53
+ import type { RingPaneModel, SparringPaneModel } from './ring-pane';
54
+ import { pointBadge, ringHost, RingRow, SparringRow } from './ring-pane';
55
55
  import { ic } from './icons';
56
56
  import type { TerminalCaps } from './terminal-caps';
57
57
  import { labelLinks } from './label-links';
@@ -83,6 +83,8 @@ export type PaneModel = {
83
83
  ) => void;
84
84
  /** El combate activo del ring en este repo; sin él, la sección no se pinta. */
85
85
  ring?: RingPaneModel | null;
86
+ /** Los sparrings activos de la sesión: una sección cada uno, tras la del combate. */
87
+ sparrings?: SparringPaneModel[];
86
88
  /** El tema de la persona es claro: el panel se queda sin el fondo navy. */
87
89
  lightTheme: boolean;
88
90
  /** Lo que sabe dibujar la terminal; sin dato, todo (Ghostty). */
@@ -1017,6 +1019,14 @@ const NextLine = (el: Elements, next: MyDay, ms: number) => {
1017
1019
  );
1018
1020
  };
1019
1021
 
1022
+ /** Una sección por sparring activo, tras la del combate; nada sin sparrings. */
1023
+ const Sparrings = (el: Elements, m: PaneModel, width: number) =>
1024
+ m.sparrings?.length ? (
1025
+ <el.Box key="sparrings" flexDirection="column" gap={1}>
1026
+ {m.sparrings.map((s) => SparringRow(el, s, width))}
1027
+ </el.Box>
1028
+ ) : null;
1029
+
1020
1030
  /**
1021
1031
  * La vista «Hoy». Desde las 16:00, sin plan de hoy y con el de mañana listo, muestra el de mañana
1022
1032
  * (sus puntos y la fecha arriba); con plan de hoy, agrega una línea con el de mañana.
@@ -1046,6 +1056,7 @@ const Today = (el: Elements, day: MyDay, m: PaneModel, columns: number) => {
1046
1056
  </Box>
1047
1057
  )}
1048
1058
  {m.ring && !host && RingRow(el, m.ring, columns - GUTTER)}
1059
+ {Sparrings(el, m, columns - GUTTER)}
1049
1060
  {shown === next && <Text dimColor>Hoy</Text>}
1050
1061
  {Emergents(el, day, m, columns)}
1051
1062
  {Complements(el, day, m, columns)}
@@ -1147,6 +1158,9 @@ export function renderPane($: EngineInterface, e: RenderEvent, m: PaneModel) {
1147
1158
  m.ring &&
1148
1159
  m.tab === 'hoy' &&
1149
1160
  RingRow(el, m.ring, columns - GUTTER)}
1161
+ {(!day || closing) &&
1162
+ m.tab === 'hoy' &&
1163
+ Sparrings(el, m, columns - GUTTER)}
1150
1164
  {riskDay && Risks(el, riskDay.risks, m, columns)}
1151
1165
  {day &&
1152
1166
  m.tab === 'hoy' &&
@@ -27,7 +27,8 @@ import {
27
27
  } from './api';
28
28
  import { renderPane } from './pane';
29
29
  import { deriveRing } from './ring';
30
- import { readIconFont, readRing } from './ring-io';
30
+ import { readIconFont, readRing, readSparrings } from './ring-io';
31
+ import { deriveSparring } from './sparring';
31
32
  import { iconSource, loadIconBytes } from './icon-files';
32
33
  import { ic, setIconFont } from './icons';
33
34
  import { terminalCaps } from './terminal-caps';
@@ -55,6 +56,8 @@ const RING_TICK_MS = 1_000;
55
56
  const RING_READ_EVERY = 2;
56
57
  /** Clave de la fila «Revisión #N» en `opened`. */
57
58
  const RING_ROW = 'ring';
59
+ /** El prefijo de un sparring: su fila en `opened` y sus llaves (`sparring-<slug>-chip-rudo`). */
60
+ const sparringScope = (slug: string) => `sparring-${slug}`;
58
61
 
59
62
  const EMPTY: Snapshot = {
60
63
  day: null,
@@ -100,7 +103,7 @@ const isBandHidden = atom(
100
103
 
101
104
  export const ring = atom(
102
105
  { plugin: 'battlearena', key: 'ring' } as const,
103
- { bout: null, readAt: 0 } as RingSnapshot,
106
+ { bout: null, sparrings: [], readAt: 0 } as RingSnapshot,
104
107
  );
105
108
  export const ringChip = atom(
106
109
  { plugin: 'battlearena', key: 'ringChip' } as const,
@@ -154,12 +157,13 @@ async function refreshRing($: EngineInterface): Promise<void> {
154
157
  const refs = [...(day?.items ?? []), ...(next?.items ?? [])].flatMap(
155
158
  (item) => item.brief?.refs ?? [],
156
159
  );
157
- const [bout, iconFont] = await Promise.all([
160
+ const [bout, sparrings, iconFont] = await Promise.all([
158
161
  readRing($, refs),
162
+ readSparrings($),
159
163
  readIconFont($),
160
164
  ]);
161
165
  const readAt = await $.clock.now();
162
- await update($, ring, () => ({ bout, readAt, iconFont }));
166
+ await update($, ring, () => ({ bout, sparrings, readAt, iconFont }));
163
167
  }
164
168
 
165
169
  /**
@@ -169,10 +173,13 @@ async function refreshRing($: EngineInterface): Promise<void> {
169
173
  async function ringBeat($: EngineInterface, beat: number): Promise<void> {
170
174
  if (!(await isPaneShown($))) return;
171
175
  if (beat % RING_READ_EVERY === 0) await refreshRing($);
172
- const { bout } = await read($, ring);
173
- if (!bout) return;
174
- const view = deriveRing(bout, await $.clock.now());
175
- if (view.running) await update($, ringTick, (n) => n + 1);
176
+ const { bout, sparrings = [] } = await read($, ring);
177
+ if (!bout && !sparrings.length) return;
178
+ const now = await $.clock.now();
179
+ const running =
180
+ (bout && deriveRing(bout, now).running) ||
181
+ sparrings.some((s) => deriveSparring(s, now).running);
182
+ if (running) await update($, ringTick, (n) => n + 1);
176
183
  await probeChipImage($);
177
184
  }
178
185
 
@@ -416,40 +423,62 @@ export const register: Register = (on) => {
416
423
  });
417
424
 
418
425
  on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
419
- const { bout, iconFont = false } = await read($, ring);
426
+ const { bout, sparrings = [], iconFont = false } = await read($, ring);
420
427
  const caps = terminalCaps(await $.env.get('TERM_PROGRAM'));
421
428
  // Los símbolos de todo Ringside van con la fuente de íconos si está instalada.
422
429
  setIconFont(iconFont, caps.glyphsFit);
423
430
  const now = await $.clock.now();
431
+ // Lo que comparten las secciones del ring (el combate y cada sparring); cada una con su fila en
432
+ // `opened` y su ficha abierta en `ringChip` (la de un sparring, con su prefijo: una a la vez).
433
+ const section = async (row: string, scope?: string) => {
434
+ const key = (id: string) => (scope ? `${scope}/${id}` : id);
435
+ const prefix = scope ? `${scope}/` : null;
436
+ const chip = await read($, ringChip);
437
+ return {
438
+ scope,
439
+ iconFont,
440
+ iconDir: iconDirOf($),
441
+ imagesDraw: (await read($, ringImages)).draws,
442
+ onProbe: (shape: typeof chipShape) => {
443
+ chipShape = shape;
444
+ },
445
+ light: await isLightTheme($),
446
+ isTerminal: e.surface === 'terminal',
447
+ tick: await read($, ringTick),
448
+ now,
449
+ isOpen: (await read($, opened)).includes(row),
450
+ onRow: () =>
451
+ void update($, opened, (list) =>
452
+ list.includes(row) ? list.filter((x) => x !== row) : [...list, row],
453
+ ).then(() => probeChipImage($)),
454
+ selected: prefix
455
+ ? chip?.startsWith(prefix)
456
+ ? chip.slice(prefix.length)
457
+ : null
458
+ : chip,
459
+ onChip: (id: string) =>
460
+ void update($, ringChip, (current) =>
461
+ current === key(id) ? null : key(id),
462
+ ),
463
+ onOpen: (command: string) =>
464
+ void $.command
465
+ .run({ command })
466
+ .catch(() => $.ui.toast(`Escribe /${command}`)),
467
+ };
468
+ };
424
469
  const ringModel = bout && {
425
- iconFont,
426
- iconDir: iconDirOf($),
427
- imagesDraw: (await read($, ringImages)).draws,
428
- onProbe: (shape: typeof chipShape) => {
429
- chipShape = shape;
430
- },
431
- light: await isLightTheme($),
432
- isTerminal: e.surface === 'terminal',
470
+ ...(await section(RING_ROW)),
433
471
  view: deriveRing(bout, now),
434
- tick: await read($, ringTick),
435
- now,
436
- isOpen: (await read($, opened)).includes(RING_ROW),
437
- onRow: () =>
438
- void update($, opened, (list) =>
439
- list.includes(RING_ROW)
440
- ? list.filter((x) => x !== RING_ROW)
441
- : [...list, RING_ROW],
442
- ).then(() => probeChipImage($)),
443
- selected: await read($, ringChip),
444
- onChip: (id: string) =>
445
- void update($, ringChip, (current) => (current === id ? null : id)),
446
- onOpen: (command: string) =>
447
- void $.command
448
- .run({ command })
449
- .catch(() => $.ui.toast(`Escribe /${command}`)),
450
472
  };
473
+ const sparringModels = await Promise.all(
474
+ sparrings.map(async (s) => ({
475
+ ...(await section(sparringScope(s.slug), sparringScope(s.slug))),
476
+ view: deriveSparring(s, now),
477
+ })),
478
+ );
451
479
  return renderPane($, e, {
452
480
  ring: ringModel,
481
+ sparrings: sparringModels,
453
482
  lightTheme: await isLightTheme($),
454
483
  caps,
455
484
  onOpenUrl: (url) =>
@@ -1,6 +1,6 @@
1
1
  import type { EngineInterface } from 'claude-code';
2
2
 
3
- import type { RingBout } from '../types';
3
+ import type { RingBout, RingSparring } from '../types';
4
4
  import {
5
5
  bellsOf,
6
6
  defenseOf,
@@ -9,6 +9,8 @@ import {
9
9
  refersTo,
10
10
  slugFromRemote,
11
11
  } from './ring';
12
+ import type { SparringFile } from './sparring';
13
+ import { sparringOf, sparringsFor } from './sparring';
12
14
 
13
15
  /**
14
16
  * Lee el combate activo del repo de la sesión desde los archivos del plugin `ring`
@@ -233,6 +235,62 @@ export async function readRing(
233
235
  }
234
236
  }
235
237
 
238
+ /** Los archivos de un sparring que cuentan como «tocarlo». */
239
+ const SPARRING_FILES = ['sparring.json', 'card.md', 'live.jsonl'];
240
+
241
+ /**
242
+ * Los sparrings activos que muestra esta sesión (`~/.claude/ring/sparrings/<slug>/`): los que ella
243
+ * registró o, sin ninguno, los que trabajan en su carpeta (ver `sparringsFor`), cada uno con su feed.
244
+ * Vacío sin ring o sin sparrings; nunca falla.
245
+ */
246
+ export async function readSparrings(
247
+ $: EngineInterface,
248
+ ): Promise<RingSparring[]> {
249
+ try {
250
+ const [root, home, sessionId, now] = await Promise.all([
251
+ $.session.root(),
252
+ ringHome($),
253
+ $.session.id().catch(() => null),
254
+ $.clock.now(),
255
+ ]);
256
+ if (!home) return [];
257
+ const base = `${home}/sparrings`;
258
+ const entries = await $.fs.list(base).catch(() => []);
259
+ const found = await Promise.all(
260
+ entries
261
+ .filter((entry) => entry.kind === 'dir')
262
+ .map(async (entry) => {
263
+ const dir = `${base}/${entry.name}`;
264
+ const [state, files] = await Promise.all([
265
+ readJson<SparringFile>($, `${dir}/sparring.json`),
266
+ $.fs.list(dir).catch(() => []),
267
+ ]);
268
+ const touched = Math.max(
269
+ 0,
270
+ ...files
271
+ .filter((f) => SPARRING_FILES.includes(f.name))
272
+ .map((f) => f.mtimeMs),
273
+ );
274
+ return state ? { dir, state, touched } : null;
275
+ }),
276
+ );
277
+ const shown = sparringsFor(
278
+ found.filter((s) => s !== null),
279
+ { sessionId, root, now },
280
+ );
281
+ return await Promise.all(
282
+ shown.map(async ({ dir, state }) =>
283
+ sparringOf(
284
+ state,
285
+ parseEvents(await $.fs.read(`${dir}/live.jsonl`).catch(() => ''), []),
286
+ ),
287
+ ),
288
+ );
289
+ } catch {
290
+ return [];
291
+ }
292
+ }
293
+
236
294
  /**
237
295
  * ¿Está instalada la fuente de íconos? Lo dice `iconFont: true` en `~/.claude/battle-arena/config.json`,
238
296
  * que escribe el instalador al copiarla: un mod no puede preguntarle a la terminal por sus fuentes.