@thebattlearena/corner 0.8.0 → 0.9.1-beta.70

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
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -163,6 +163,10 @@ type Setup = {
163
163
  remote?: string;
164
164
  /** Aquí se anotan los `argv` de cada `process.run`. */
165
165
  runs?: string[][];
166
+ /** Sparrings en `<RING_HOME>/sparrings/<slug>/`: su `sparring.json` y su feed. */
167
+ sparrings?: Record<string, { state: object; live: object[] }>;
168
+ /** El id de la sesión (`$.session.id()`). */
169
+ sessionId?: string;
166
170
  };
167
171
 
168
172
  const engine = (on: On, setup: Setup = {}) => {
@@ -179,6 +183,8 @@ const engine = (on: On, setup: Setup = {}) => {
179
183
  termProgram,
180
184
  remote = 'git@github.com:julio-supervisor/agents-studio-be.git\n',
181
185
  runs = [],
186
+ sparrings = {},
187
+ sessionId = 'sesion-1',
182
188
  } = setup;
183
189
  const files: Record<string, string> = {
184
190
  '/Users/test/.claude/battle-arena/config.json': JSON.stringify({
@@ -196,6 +202,15 @@ const engine = (on: On, setup: Setup = {}) => {
196
202
  [`${BOUT}/live.jsonl`]: live.map((e) => JSON.stringify(e)).join('\n'),
197
203
  [`${BOUT}/title-defense.md`]: CATALOGUE,
198
204
  [`${ROOT}/.claude/ring.json`]: JSON.stringify({ monitors }),
205
+ ...Object.fromEntries(
206
+ Object.entries(sparrings).flatMap(([slug, { state, live: feed }]) => [
207
+ [`${HOME}/sparrings/${slug}/sparring.json`, JSON.stringify(state)],
208
+ [
209
+ `${HOME}/sparrings/${slug}/live.jsonl`,
210
+ feed.map((e) => JSON.stringify(e)).join('\n'),
211
+ ],
212
+ ]),
213
+ ),
199
214
  };
200
215
  const dirs: Record<string, string[]> = {
201
216
  [HOME]: ['julio-supervisor'],
@@ -210,6 +225,13 @@ const engine = (on: On, setup: Setup = {}) => {
210
225
  'round-15-b.md',
211
226
  'round6-since.txt',
212
227
  ],
228
+ [`${HOME}/sparrings`]: Object.keys(sparrings),
229
+ ...Object.fromEntries(
230
+ Object.keys(sparrings).map((slug) => [
231
+ `${HOME}/sparrings/${slug}`,
232
+ ['sparring.json', 'live.jsonl', 'card.md'],
233
+ ]),
234
+ ),
213
235
  };
214
236
  on('session.start', (_, e) => ({ cwd: e.cwd }));
215
237
  on('clock.now', () => ({ value: NOW }));
@@ -252,6 +274,7 @@ const engine = (on: On, setup: Setup = {}) => {
252
274
  on('ui.open', () => ({ value: { isPlaced: true } }));
253
275
  on('ui.toast', () => ({ value: undefined }));
254
276
  on('session.root', () => ({ value: ROOT }));
277
+ on('session.id', () => ({ value: sessionId }));
255
278
  on('env.get', (_, e) => {
256
279
  if (e.name === 'TERM_PROGRAM') return { value: termProgram };
257
280
  return { value: e.name === 'RING_HOME' ? HOME : '/Users/test' };
@@ -1346,3 +1369,134 @@ for (const termProgram of [undefined, 'Apple_Terminal'])
1346
1369
  expect((await ui.find({ key: 'ring-chip-bateria' }))?.text).toContain('⚠');
1347
1370
  await ui.unmount();
1348
1371
  });
1372
+
1373
+ const SPARRING = {
1374
+ slug: 'tira-morada',
1375
+ verdict: { call: 'rematch', note: 'oculta los totales', at: ago(7200) },
1376
+ history: [{ call: 'rematch' }],
1377
+ repos: [
1378
+ {
1379
+ repo: 'acme/api',
1380
+ worktree: '/wt/api-sparring',
1381
+ pr: 7,
1382
+ url: 'https://github.com/acme/api/pull/7',
1383
+ },
1384
+ { repo: 'acme/web', worktree: '/wt/web-sparring', pr: null, url: null },
1385
+ ],
1386
+ sessions: [{ id: 'sesion-1', role: 'referee' }],
1387
+ };
1388
+
1389
+ const SPARRING_LIVE = [
1390
+ {
1391
+ at: ago(900),
1392
+ actor: 'arbitro',
1393
+ state: 'done',
1394
+ label: 'abrió el PR',
1395
+ id: 'p',
1396
+ },
1397
+ {
1398
+ at: ago(240),
1399
+ actor: 'rudo',
1400
+ state: 'active',
1401
+ label: 'tira-morada acme/web',
1402
+ id: 'r1',
1403
+ detail: 'tira-morada acme/web',
1404
+ },
1405
+ ];
1406
+
1407
+ test('sparring: su sección con la ronda, el veredicto, sus repos y el rudo trabajando', async ($, on) => {
1408
+ engine(on, {
1409
+ sparrings: { 'tira-morada': { state: SPARRING, live: SPARRING_LIVE } },
1410
+ });
1411
+ await start($);
1412
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' });
1413
+ const row = 'sparring-tira-morada-row';
1414
+ expect(await ui.find({ key: row })).toBeTruthy();
1415
+ expect(
1416
+ await ui.find({ type: 'Text', text: 'Sparring tira-morada' }),
1417
+ ).toBeTruthy();
1418
+ // Va tras los puntos del plan, como un combate sin punto anfitrión.
1419
+ const tree = await texts(ui);
1420
+ expect(tree.indexOf('Programar lotes desde el panel')).toBeLessThan(
1421
+ tree.indexOf('Sparring tira-morada'),
1422
+ );
1423
+ expect(await ui.find({ key: 'sparring-tira-morada-chip-rudo' })).toBeFalsy();
1424
+ await ui.press({ key: row });
1425
+ expect(await ui.find({ type: 'Text', text: 'ronda 2' })).toBeTruthy();
1426
+ expect(
1427
+ await ui.find({ type: 'Text', text: 'último veredicto: rematch' }),
1428
+ ).toBeTruthy();
1429
+ expect(
1430
+ await ui.find({ key: 'sparring-tira-morada-repo-acme/api', text: /#7/ }),
1431
+ ).toBeTruthy();
1432
+ expect(
1433
+ await ui.find({
1434
+ key: 'sparring-tira-morada-repo-acme/web',
1435
+ text: /sin PR/,
1436
+ }),
1437
+ ).toBeTruthy();
1438
+ // La tira: árbitro, rudo y juez; el rudo trabaja (su barrita), el combate sigue con la suya.
1439
+ for (const id of ['arbitro', 'rudo', 'juez'])
1440
+ expect(
1441
+ await ui.find({ key: `sparring-tira-morada-chip-${id}` }),
1442
+ ).toBeTruthy();
1443
+ expect(
1444
+ await ui.find({ key: 'sparring-tira-morada-chip-luchador' }),
1445
+ ).toBeFalsy();
1446
+ expect(
1447
+ await ui.find({
1448
+ key: 'sparring-tira-morada-chip-rudo',
1449
+ text: /[▰▱]{4}/,
1450
+ }),
1451
+ ).toBeTruthy();
1452
+ expect(
1453
+ await ui.find({ type: 'Text', text: 'tira-morada acme/web' }),
1454
+ ).toBeTruthy();
1455
+ // Abrir la ficha del rudo no abre ninguna del combate.
1456
+ await ui.press({ key: 'sparring-tira-morada-chip-rudo' });
1457
+ expect(await ui.find({ key: 'sparring-tira-morada-field-en' })).toBeTruthy();
1458
+ expect(await ui.find({ key: 'ring-field-en' })).toBeFalsy();
1459
+ await ui.unmount();
1460
+ });
1461
+
1462
+ test('sparring: una pregunta abierta enciende al juez; uno con ko o de otra sesión no se pinta', async ($, on) => {
1463
+ engine(on, {
1464
+ sparrings: {
1465
+ 'tira-morada': {
1466
+ state: SPARRING,
1467
+ live: [
1468
+ ...SPARRING_LIVE,
1469
+ {
1470
+ at: ago(30),
1471
+ actor: 'juez',
1472
+ state: 'waiting',
1473
+ label: 'te toca decidir',
1474
+ id: 'q1',
1475
+ },
1476
+ ],
1477
+ },
1478
+ ganada: {
1479
+ state: {
1480
+ ...SPARRING,
1481
+ slug: 'ganada',
1482
+ verdict: { call: 'ko', note: 'x' },
1483
+ },
1484
+ live: [],
1485
+ },
1486
+ ajena: {
1487
+ state: { ...SPARRING, slug: 'ajena', sessions: [{ id: 'otra' }] },
1488
+ live: [],
1489
+ },
1490
+ },
1491
+ });
1492
+ await start($);
1493
+ const ui = await $.ui.mount({ ...PANE, surface: 'terminal' });
1494
+ expect(await ui.find({ key: 'sparring-ganada-row' })).toBeFalsy();
1495
+ expect(await ui.find({ key: 'sparring-ajena-row' })).toBeFalsy();
1496
+ await ui.press({ key: 'sparring-tira-morada-row' });
1497
+ expect(
1498
+ await ui.find({ key: 'sparring-tira-morada-chip-juez', text: 'tú' }),
1499
+ ).toBeTruthy();
1500
+ expect(await ui.find({ type: 'Text', text: /TE TOCA DECIDIR/ })).toBeTruthy();
1501
+ await ui.unmount();
1502
+ });
@@ -416,13 +416,15 @@ test('paleta: cada actor con su color; un monitor pide uno de la paleta o va en
416
416
  e2e: 'pink',
417
417
  mal: 'green',
418
418
  });
419
- expect(Object.keys(ACTOR_COLOR)).toHaveLength(6);
420
- expect(new Set(Object.values(ACTOR_COLOR)).size).toBe(6);
419
+ // El rudo (sparring) no va en la tira de un combate, pero tiene su color: el rosa.
420
+ expect(ACTOR_COLOR.rudo).toBe('pink');
421
+ expect(Object.keys(ACTOR_COLOR)).toHaveLength(7);
422
+ expect(new Set(Object.values(ACTOR_COLOR)).size).toBe(7);
421
423
  expect(Object.keys(PALETTE)).toContain('green');
422
424
  });
423
425
 
424
- test('los íconos salen de la fuente: los 58, con su glifo, sus letras y sus PNG', () => {
425
- expect(Object.keys(ICONS)).toHaveLength(58);
426
+ test('los íconos salen de la fuente: los 59, con su glifo, sus letras y sus PNG', () => {
427
+ expect(Object.keys(ICONS)).toHaveLength(59);
426
428
  expect(ICONS.trofeo).toEqual({
427
429
  glyph: String.fromCodePoint(0xfba57),
428
430
  alt: 'TR',
@@ -431,6 +433,13 @@ test('los íconos salen de la fuente: los 58, con su glifo, sus letras y sus PNG
431
433
  cells: 1,
432
434
  });
433
435
  expect(ICONS.luchador.tints).toEqual(['blue', 'red', 'amber']);
436
+ expect(ICONS.rudo).toEqual({
437
+ glyph: String.fromCodePoint(0xfba16),
438
+ alt: 'RU',
439
+ png: 'rudo',
440
+ tints: ['pink', 'red', 'amber'],
441
+ cells: 1,
442
+ });
434
443
  expect(
435
444
  monitorsOf({ monitors: [{ id: 'copa', label: 'Copa', icon: 'trofeo' }] }),
436
445
  ).toEqual([{ id: 'copa', label: 'Copa', icon: 'trofeo' }]);
@@ -0,0 +1,204 @@
1
+ import { expect, test } from 'claude-code/testing';
2
+
3
+ import type { RingEvent } from '../types';
4
+ import type { SparringFile } from '../hooks/sparring';
5
+ import { deriveRing, monitorsOf, parseEvents } from '../hooks/ring';
6
+ import {
7
+ deriveSparring,
8
+ isActiveSparring,
9
+ roundOf,
10
+ SPARRING_FRESH_MS,
11
+ sparringOf,
12
+ sparringsFor,
13
+ } from '../hooks/sparring';
14
+
15
+ const NOW = Date.parse('2026-10-24T20:00:00Z');
16
+ const ago = (seconds: number) => new Date(NOW - seconds * 1000).toISOString();
17
+ const HOUR = 3_600_000;
18
+
19
+ const file = (slug: string, extra: Partial<SparringFile> = {}) => ({
20
+ slug,
21
+ verdict: null,
22
+ repos: [
23
+ {
24
+ repo: 'acme/web',
25
+ worktree: `/wt/web-sparring-${slug}`,
26
+ pr: null,
27
+ url: null,
28
+ },
29
+ ],
30
+ sessions: [{ id: 's1' }],
31
+ ...extra,
32
+ });
33
+
34
+ test('activo: sin veredicto final (un rematch sigue) y tocado en las últimas 12 h', () => {
35
+ expect(isActiveSparring(file('a'), NOW - HOUR, NOW)).toBe(true);
36
+ expect(
37
+ isActiveSparring(
38
+ file('a', { verdict: { call: 'rematch', note: 'x' } }),
39
+ NOW,
40
+ NOW,
41
+ ),
42
+ ).toBe(true);
43
+ expect(
44
+ isActiveSparring(
45
+ file('a', { verdict: { call: 'ko', note: 'x' } }),
46
+ NOW,
47
+ NOW,
48
+ ),
49
+ ).toBe(false);
50
+ expect(
51
+ isActiveSparring(
52
+ file('a', { verdict: { call: 'discard', note: 'x' } }),
53
+ NOW,
54
+ NOW,
55
+ ),
56
+ ).toBe(false);
57
+ expect(isActiveSparring(file('a'), NOW - SPARRING_FRESH_MS, NOW)).toBe(false);
58
+ expect(isActiveSparring({ slug: 3 }, NOW, NOW)).toBe(false);
59
+ });
60
+
61
+ test('qué sparrings muestra la sesión: los que registró; sin ninguno, los de su carpeta', () => {
62
+ const found = [
63
+ { state: file('vieja'), touched: NOW - 2 * HOUR },
64
+ { state: file('nueva'), touched: NOW - HOUR },
65
+ { state: file('ajena', { sessions: [{ id: 's2' }] }), touched: NOW },
66
+ {
67
+ state: file('ganada', { verdict: { call: 'ko', note: 'x' } }),
68
+ touched: NOW,
69
+ },
70
+ ];
71
+ const slugs = (list: typeof found) => list.map((s) => s.state.slug);
72
+ expect(
73
+ slugs(sparringsFor(found, { sessionId: 's1', root: '/repo', now: NOW })),
74
+ ).toEqual(['nueva', 'vieja']);
75
+ // Otra sesión que no registró ninguno: los que trabajan en su carpeta (o debajo de ella).
76
+ expect(
77
+ slugs(
78
+ sparringsFor(found, {
79
+ sessionId: 's9',
80
+ root: '/wt/web-sparring-ajena/src',
81
+ now: NOW,
82
+ }),
83
+ ),
84
+ ).toEqual(['ajena']);
85
+ expect(
86
+ slugs(
87
+ sparringsFor(found, {
88
+ sessionId: null,
89
+ root: '/wt/web-sparring-ganada',
90
+ now: NOW,
91
+ }),
92
+ ),
93
+ ).toEqual([]);
94
+ // `/wt/web-sparring-nuevas` no es la carpeta de `nueva`.
95
+ expect(
96
+ slugs(
97
+ sparringsFor(found, {
98
+ sessionId: null,
99
+ root: '/wt/web-sparring-nuevas',
100
+ now: NOW,
101
+ }),
102
+ ),
103
+ ).toEqual([]);
104
+ });
105
+
106
+ test('la ronda: veredictos de history + 1; los de antes de history, por su veredicto', () => {
107
+ expect(roundOf(file('a'))).toBe(1);
108
+ expect(
109
+ roundOf(
110
+ file('a', {
111
+ verdict: { call: 'rematch', note: 'x' },
112
+ history: [{}, {}],
113
+ }),
114
+ ),
115
+ ).toBe(3);
116
+ expect(roundOf(file('a', { verdict: { call: 'rematch', note: 'x' } }))).toBe(
117
+ 2,
118
+ );
119
+ });
120
+
121
+ test('el sparring: sus repos con su PR, la ronda, el último veredicto y su tira (árbitro, rudo, juez)', () => {
122
+ const events = parseEvents(
123
+ [
124
+ {
125
+ at: ago(900),
126
+ actor: 'arbitro',
127
+ state: 'done',
128
+ label: 'abrió el PR',
129
+ pr: 12,
130
+ },
131
+ {
132
+ at: ago(300),
133
+ actor: 'rudo',
134
+ state: 'active',
135
+ label: 'tira-morada acme/web',
136
+ id: 'r1',
137
+ },
138
+ {
139
+ at: ago(60),
140
+ actor: 'juez',
141
+ state: 'waiting',
142
+ label: 'te toca decidir',
143
+ id: 'q1',
144
+ },
145
+ ]
146
+ .map((e) => JSON.stringify(e))
147
+ .join('\n'),
148
+ [],
149
+ );
150
+ expect(events).toHaveLength(3);
151
+ const sparring = sparringOf(
152
+ file('tira-morada', {
153
+ verdict: { call: 'rematch', note: 'oculta los totales' },
154
+ history: [{}],
155
+ repos: [
156
+ { repo: 'acme/api', pr: 7, url: 'https://github.com/acme/api/pull/7' },
157
+ { repo: 'acme/web' },
158
+ ],
159
+ }),
160
+ events,
161
+ );
162
+ expect(sparring).toMatchObject({
163
+ slug: 'tira-morada',
164
+ round: 2,
165
+ verdict: { call: 'rematch', note: 'oculta los totales' },
166
+ repos: [
167
+ { repo: 'acme/api', pr: 7, url: 'https://github.com/acme/api/pull/7' },
168
+ { repo: 'acme/web', pr: null, url: null },
169
+ ],
170
+ });
171
+ const view = deriveSparring(sparring, NOW);
172
+ expect(view.chips.map((c) => [c.id, c.color, c.mode])).toEqual([
173
+ ['arbitro', 'slate', 'done'],
174
+ ['rudo', 'pink', 'active'],
175
+ ['juez', 'orange', 'waiting'],
176
+ ]);
177
+ expect(view.running).toBe(true);
178
+ expect(view.waiting).toBe(true);
179
+ expect(view.live?.id).toBe('juez');
180
+ });
181
+
182
+ test('el rudo no entra a la tira de un combate, y ningún monitor puede llamarse rudo', () => {
183
+ const rudo: RingEvent = {
184
+ at: ago(10),
185
+ actor: 'rudo',
186
+ state: 'active',
187
+ label: 'x',
188
+ };
189
+ const view = deriveRing(
190
+ {
191
+ slug: 'acme/api',
192
+ pr: 1,
193
+ playByPlayUrl: null,
194
+ defense: null,
195
+ bells: 0,
196
+ events: [rudo],
197
+ monitors: [],
198
+ commands: [],
199
+ },
200
+ NOW,
201
+ );
202
+ expect(view.chips.map((c) => c.id)).not.toContain('rudo');
203
+ expect(monitorsOf({ monitors: [{ id: 'rudo', label: 'Rudo' }] })).toEqual([]);
204
+ });
@@ -247,7 +247,8 @@ export type RingActorId =
247
247
  | 'esquinero'
248
248
  | 'campana'
249
249
  | 'copilot'
250
- | 'juez';
250
+ | 'juez'
251
+ | 'rudo';
251
252
 
252
253
  export type RingEventState = 'active' | 'waiting' | 'done' | 'failed';
253
254
 
@@ -314,6 +315,27 @@ export type RingBout = {
314
315
  commands: string[];
315
316
  };
316
317
 
318
+ /** Un repo de un sparring: su PR (borrador) cuando ya se abrió. */
319
+ export type RingSparringRepo = {
320
+ repo: string;
321
+ pr: number | null;
322
+ url: string | null;
323
+ };
324
+
325
+ /**
326
+ * Un sparring activo, leído de `~/.claude/ring/sparrings/<slug>/` (`sparring.json` y su
327
+ * `live.jsonl`): no es un combate, pero se pinta como uno, con su propia tira (árbitro, rudo, juez).
328
+ */
329
+ export type RingSparring = {
330
+ slug: string;
331
+ repos: RingSparringRepo[];
332
+ /** La que se pelea: veredictos de `history` + 1. */
333
+ round: number;
334
+ /** El último veredicto (un `rematch`, si sigue activo); `null` mientras el juez no dicta. */
335
+ verdict: { call: string; note: string } | null;
336
+ events: RingEvent[];
337
+ };
338
+
317
339
  /** Si la terminal dibuja las imágenes de las fichas (`null` sin probar) y por qué no. */
318
340
  export type RingImages = { draws: boolean | null; reason: string | null };
319
341
 
@@ -327,6 +349,8 @@ export type IconsDiag = {
327
349
 
328
350
  export type RingSnapshot = {
329
351
  bout: RingBout | null;
352
+ /** Los sparrings activos de esta sesión (o de su carpeta); vacío sin ninguno. */
353
+ sparrings?: RingSparring[];
330
354
  readAt: number;
331
355
  /** `iconFont: true` en la config: la fuente Battle Arena Icons está instalada. */
332
356
  iconFont?: boolean;
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ring",
3
3
  "version": "0.1.0",
4
- "description": "A way of working for backend, frontend and mobile repos, each at its own venue: a referee session at high effort, one long-lived wrestler at medium, read-only cornermen at high, small do-not-merge review PRs, rebounds, a numbered rulebook and a durable judges card.",
4
+ "description": "A way of working for backend, frontend and mobile repos, each at its own venue: a referee session at high effort, one long-lived wrestler at medium, read-only cornermen at high, small do-not-merge review PRs, rebounds, a numbered rulebook and a durable judges card, plus sparring drafts (a rudo at medium) that prove a feature is worth building before it is built well.",
5
5
  "author": {
6
6
  "name": "Battle Arena"
7
7
  }
@@ -0,0 +1,95 @@
1
+ ---
2
+ name: rudo
3
+ description: The ring's sparring executor. Builds a quick, deliberately low-quality draft of a feature in its own worktree, only to prove whether it is worth building; never merged. Launched and directed by the ring referee from `/ring:sparring`; takes every doubt to the referee (never to the human), works inside a timebox and lists every shortcut it took. Suspends the style and test rules, never the live-operation ones.
4
+ tools: Read, Grep, Glob, Edit, Write, Bash
5
+ effort: medium
6
+ ---
7
+
8
+ You are the rudo: in lucha libre the rudo wins however he can, the técnico wins clean. You are
9
+ the first one in. Your job is to make the feature **work well enough for the judge to try it, in
10
+ the whole experience it lives in**, inside the timebox, and to say honestly which corners you cut. If the judge calls it a KO, a ring
11
+ bout refactors your branch later; nobody expects clean code from you.
12
+
13
+ ## What you may skip
14
+
15
+ - The rulebook's style and design rules (R): layers, patterns, specifications, mappers, file
16
+ size, naming polish. Inline what is faster.
17
+ - The test rules (T): no coverage needed. Only the typecheck gate must pass.
18
+ - Cornermen, rebounds and round-bells: there are none in a sparring.
19
+ - Contract first: the API or model change is a draft too, reviewed later in the bout.
20
+
21
+ ## What you never skip
22
+
23
+ - **Live operation (rule F):** no real money, messages, calls or customer events. Sandbox or test
24
+ modes only. The one exception, when your brief grants it: a real send to the judge's own
25
+ destinations, read from `SPARRING_ALLOWED_<KIND>` in `.env.sparring` (comma-separated, filled by
26
+ the judge). Closed by default: an empty list sends nothing; any other destination is recorded
27
+ with `{ sent: false, reason: "not_allowlisted" }`. Never write a destination in code, a fixture,
28
+ a commit or a log; log only whether it was sent.
29
+ - **Secrets:** credentials are names in your brief; their values live in
30
+ `<worktree>/.env.sparring`, filled by the judge. Never commit, print, echo, log or paste a
31
+ value, nor put one in code, a fixture or a commit message. `mask-guard` blocks committing
32
+ `.env.sparring`.
33
+ - **Database:** additive only (new tables or columns, English names), never against production.
34
+ Never drop or alter what exists.
35
+ - Customer data and PII stay out of fixtures and seeds.
36
+ - **Never write to a shared database** (dev, staging). When the proof needs seeded data, prepare
37
+ it in `<sparring dir>/tools/`: synthetic rows marked with the slug, values that reach nobody
38
+ (unassigned phone prefixes, `example.invalid` emails), a dry run, and a cleanup that deletes only
39
+ the marked rows. Report the commands; the judge runs them. If a write is blocked, do not retry it
40
+ another way.
41
+ - Stage by explicit paths (never `git add -A` or `.`); no `--no-verify`; when the brief's
42
+ `forbidAiTrailers` is true (the default), no AI attribution trailers.
43
+ - Push only to `sparring/<slug>`, never force. No ready PRs, merges or base changes: `mask-guard`
44
+ blocks them on a sparring branch, bout or not.
45
+ - Write only inside your worktree; a helper goes in the sparring dir's `tools/`, never in the repo.
46
+ - One heavy command at a time, through `node "<ring>/scripts/one-at-a-time.mjs" run -- <command>`.
47
+ - **Background processes by PID only.** Note the PID of every server, watcher or `tail`/`cat` you
48
+ leave running (`echo $!` right after starting it) and stop it with `kill <pid>` before you
49
+ report. Never `pkill`, `killall` or `kill $(pgrep …)`: other sessions on the machine run
50
+ processes with the same name. Whatever you leave running for the judge to try (the server
51
+ of the proof), list it in your report with its PID.
52
+
53
+ ## How you work
54
+
55
+ 1. `git -C <worktree> status` must be clean before you start. Every git command is
56
+ `git -C <worktree> …`.
57
+ 2. Build the **full screen or flow** of your brief, the way the design draws it, **working**: the
58
+ judge must see the whole experience and every visible control must do something (search,
59
+ filters, tabs, loaders, pagination or infinite scroll, actions and the alert when they fail).
60
+ Data comes from the back, built quick and dirty; a mock is only for what cannot be had yet.
61
+ Never a placeholder that says "comes later", never a button that does nothing. Speed comes
62
+ from the quality you skip, not from the scope or the depth you cut. Before you report, click
63
+ every control in a browser and list any that still does nothing.
64
+ - **Designs** in the brief are followed as closely as the timebox allows. A `.pen` file is read
65
+ only through the pencil MCP tools, never with Read or Grep (it is encrypted).
66
+ - **Other repos of the sparring:** when you produce what another repo consumes (an SDK, an
67
+ endpoint), leave it in `<sparring dir>/tools/` (an SDK as an `npm pack` tarball) and report its
68
+ path, your port and one `curl` that proves it. When you consume, install it from there, never
69
+ from a registry the sparring published to; until it arrives, a mock behind an env var is a
70
+ shortcut to list. Locally, accept requests from any `http://localhost:<port>` origin (CORS),
71
+ never one fixed port, and list it as a shortcut.
72
+ 3. Keep a running list of **shortcuts** as you take them ("hardcoded the workspace id", "no
73
+ pagination", "mapping inline in the repository"): it becomes the first rebound of the bout.
74
+ 4. Commit often, conventional messages in the language of the branch's previous commits. Push to
75
+ `sparring/<slug>` when something is worth trying.
76
+ 5. When the timebox runs out, stop where you are, push what works and report.
77
+ 6. Every doubt goes to the referee in your report, with two options each. Never to the human.
78
+
79
+ ## What you return (every time you push or stop)
80
+
81
+ ```
82
+ Sparring: <slug> · tip <sha> (pushed to sparring/<slug>)
83
+ Proof of value: reachable ✓ | partial: <what is missing> | not reached: <why>
84
+ How to try it: <how to start it, its port, env vars, sample input>
85
+ Dead controls: none | <control — why> (from your click-through)
86
+ Judge opens: <exact URL of each screen> · enters as <user / workspace / account>
87
+ Left running: none | <process, PID, port> (only what the judge needs to try it)
88
+ Hand-off: none | <path in tools/, port, curl> (producer) | <what you installed from tools/> (consumer)
89
+ Shortcuts: <one line each>
90
+ Credentials used (names only): <NAME, … | none>
91
+ Schema changes: none | <additive change, tables/columns>
92
+ Gate: typecheck ✓/✗ (exact command)
93
+ Questions for the referee: <numbered, 2 options each>
94
+ Time used: <h:mm> of <timebox>
95
+ ```
@@ -21,6 +21,20 @@
21
21
  // gh events ready_for_review → juez; review_requested to Copilot → campana (closes a round)
22
22
  // round-bells a round-bell PR opened → dado
23
23
  // --extra any of the types, as given, marked provided
24
+ //
25
+ // A fight that starts at a sparring (sparring-chronicle.mjs, fight.mjs) uses the same eight types,
26
+ // no new one. Its sources, classified:
27
+ // sparring.json openedAt → arbitro (the referee opened it); each verdict of history[] → juez
28
+ // (a KO closes the first fall); each repo's last preview look → bateria
29
+ // milestones (`sparring.mjs mark`) vestidor → bateria (the host gates passed; `sparring.mjs
30
+ // vestidor` stamps it); defense → juez (the judge opens it to the team, the note
31
+ // holds the hours to the bell); bell → campana; mask-kept / mask-lost → juez;
32
+ // no-team → juez («sin equipo»: no second fall, the third starts there);
33
+ // referee → arbitro (handed to the bout; `judges-card open --from-sparring` marks it)
34
+ // blows-round-<n>.md each Tape it cites → juez (the judge's attack) at the time written next to
35
+ // it; its blows (D/B/F/Q/R with their Tape minute) go to `catalogs`, not events
36
+ // live.jsonl the referee's own lines: repo added → arbitro; PR opened → dado; preview → bateria
37
+ // gh / git each sparring PR opened → dado; test/fix commits in each worktree → dado
24
38
 
25
39
  export const TYPES = [
26
40
  'reclamo',
@@ -418,6 +432,16 @@ export function localTime(at, timeZone) {
418
432
  }).format(new Date(at));
419
433
  }
420
434
 
435
+ /** YYYY-MM-DD in a time zone: a fight that spans days says which day each beat is. */
436
+ export function localDay(at, timeZone) {
437
+ return new Intl.DateTimeFormat('en-CA', {
438
+ timeZone,
439
+ year: 'numeric',
440
+ month: '2-digit',
441
+ day: '2-digit',
442
+ }).format(new Date(at));
443
+ }
444
+
421
445
  /** Did Copilot review the PR's head? Says it plainly for a merge or a close. PURE. */
422
446
  export function copilotOnHead(pr, reviews) {
423
447
  const copilot = reviews