@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
@@ -64,6 +64,22 @@ export const BUNDLED_PRECEDENTS = join(
64
64
  export const HOST_CONFIG = join('.claude', 'ring.json');
65
65
  export const HOST_PRECEDENTS = join('.claude', 'ring-precedents.md');
66
66
 
67
+ /** The label of every temporary PR the ring opens (round-bells, sparrings): never merged. */
68
+ export const DO_NOT_MERGE = 'do-not-merge';
69
+
70
+ /**
71
+ * Sparring drafts live on `sparring/<slug>`; local (`origin/`) and full refs count too, and so
72
+ * does the `owner:` prefix gh takes for a head on a fork (`--head owner:sparring/<slug>`); a git
73
+ * branch can never hold a `:`, so stripping it never mistakes a real branch. PURE.
74
+ */
75
+ export const SPARRING_PREFIX = 'sparring/';
76
+ export const isSparringBranch = (branch) =>
77
+ String(branch ?? '')
78
+ .replace(/^[^/:\s]+:/, '')
79
+ .replace(/^refs\/heads\//, '')
80
+ .replace(/^(refs\/remotes\/)?origin\//, '')
81
+ .startsWith(SPARRING_PREFIX);
82
+
67
83
  /** Who decides design doubts: the referee (default) or, as before, the human. */
68
84
  export const DELEGATION_LEVELS = ['referee', 'human'];
69
85
 
@@ -111,9 +127,23 @@ export const DEFAULTS = Object.freeze({
111
127
  // Cornerman findings a round-bell or a push tolerates: open ones from ranges inside it, and ones
112
128
  // the referee settled alone that the human has not seen (null: no gate). See findings.mjs.
113
129
  findingsGate: Object.freeze({ open: 0, unshown: 3 }),
130
+ // Sparring drafts (/ring:sparring): the label of their PRs, the GitHub deployment environment the
131
+ // host's CI publishes the preview to (null: the host has none), how long to wait for it, and after
132
+ // how many days without a verdict a sparring shows as stale.
133
+ sparring: Object.freeze({
134
+ label: 'sparring',
135
+ previewEnvironment: null,
136
+ previewTimeoutMin: 20,
137
+ staleDays: 7,
138
+ }),
114
139
  });
115
140
 
116
141
  const GATES = ['typecheck', 'test', 'lint'];
142
+ /**
143
+ * Gates detected when the repo has them, never reported missing: `build` is the sparring vestidor's
144
+ * (the host's real build before the team sees a KO), not a bout gate, so mask-guard leaves it out.
145
+ */
146
+ export const OPTIONAL_GATES = ['build'];
117
147
 
118
148
  const VENUES = bundledVenues();
119
149
  export const VENUE_NAMES = Object.keys(VENUES);
@@ -158,6 +188,11 @@ function detectNode(read, exists) {
158
188
  command: `${pm.exec} tsc --noEmit`,
159
189
  from: 'typescript + tsconfig.json',
160
190
  };
191
+ if (scripts.build)
192
+ found.build = {
193
+ command: `${pm.run} build`,
194
+ from: 'package.json script "build"',
195
+ };
161
196
  if (scripts.lint)
162
197
  found.lint = {
163
198
  command: `${pm.run} lint`,
@@ -223,6 +258,7 @@ function detectGo(read, exists) {
223
258
  command: 'go test {paths}',
224
259
  from: 'go.mod (paths are packages, e.g. ./pkg/x/...)',
225
260
  },
261
+ build: { command: 'go build ./...', from: 'go.mod' },
226
262
  };
227
263
  if (['.golangci.yml', '.golangci.yaml', '.golangci.toml'].some(exists))
228
264
  found.lint = { command: 'golangci-lint run', from: '.golangci config' };
@@ -245,6 +281,8 @@ function detectMake(read) {
245
281
  };
246
282
  if (targets.has('lint'))
247
283
  found.lint = { command: 'make lint', from: 'Makefile target "lint"' };
284
+ if (targets.has('build'))
285
+ found.build = { command: 'make build', from: 'Makefile target "build"' };
248
286
  if (targets.has('test'))
249
287
  found.test = {
250
288
  command: 'make test',
@@ -256,7 +294,8 @@ function detectMake(read) {
256
294
  /**
257
295
  * Detects the gates of a host repo from its manifests. PURE: `read(name)` returns the file text or
258
296
  * null, `exists(name)` a boolean. Language manifests win over the Makefile, which fills the gaps.
259
- * Returns the gates, where each came from, and the ones nobody could detect.
297
+ * Returns the gates, where each came from, and the ones nobody could detect (an optional gate is
298
+ * there only when detected, and never missing).
260
299
  */
261
300
  export function detectGates(read, exists) {
262
301
  const layers = [
@@ -272,6 +311,12 @@ export function detectGates(read, exists) {
272
311
  gates[gate] = hit ? hit.command : null;
273
312
  if (hit) detectedFrom[gate] = hit.from;
274
313
  }
314
+ for (const gate of OPTIONAL_GATES) {
315
+ const hit = layers.map((layer) => layer[gate]).find(Boolean);
316
+ if (!hit) continue;
317
+ gates[gate] = hit.command;
318
+ detectedFrom[gate] = hit.from;
319
+ }
275
320
  const missing = GATES.filter((gate) => !gates[gate]);
276
321
  return { gates, detectedFrom, missing };
277
322
  }
@@ -303,6 +348,7 @@ export function mergeConfig(detected, host) {
303
348
  own.findingsGate === null
304
349
  ? null
305
350
  : { ...DEFAULTS.findingsGate, ...(own.findingsGate ?? {}) },
351
+ sparring: { ...DEFAULTS.sparring, ...(own.sparring ?? {}) },
306
352
  };
307
353
  }
308
354
 
@@ -0,0 +1,238 @@
1
+ // A fight that starts at a sparring: one chronicle from the sparring's opening to the end of every
2
+ // referee bout opened `--from-sparring` it (two out of three falls), one page and one link for all
3
+ // of it. This module reads and writes the sparring's side (`sparring.json`: the chronicle's link and
4
+ // the fight's milestones) and collects the whole fight; the pure rules are sparring-chronicle.mjs.
5
+ // Zero-dependency. It never rewrites the sparring's card: neither the link nor the milestones are
6
+ // on it.
7
+ import { execFileSync } from 'node:child_process';
8
+ import { existsSync, readdirSync, readFileSync, writeFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import { commitEvents, localDay, localTime, score } from './chronicle.mjs';
12
+ import { repoBouts, slugFromRemote } from './config.mjs';
13
+ import { LIVE_LOG, SPARRING_FILE, sparringsHome } from './live.mjs';
14
+ import {
15
+ dedupe,
16
+ feedEvents,
17
+ parseBlows,
18
+ prOpenedEvents,
19
+ sliceFight,
20
+ stateEvents,
21
+ tapeEvents,
22
+ verdictsOf,
23
+ withChronicleUrl,
24
+ withMilestone,
25
+ } from './sparring-chronicle.mjs';
26
+
27
+ const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
28
+
29
+ /** `<ring home>/sparrings/<slug>`, refusing anything that is not a sparring slug. */
30
+ export function sparringDirOf(slug, env = process.env) {
31
+ if (!SLUG.test(slug ?? ''))
32
+ throw new Error(`"${slug}" is not a sparring slug`);
33
+ return join(sparringsHome(env), slug);
34
+ }
35
+
36
+ export function readSparringState(slug, env = process.env) {
37
+ const file = join(sparringDirOf(slug, env), SPARRING_FILE);
38
+ if (!existsSync(file))
39
+ throw new Error(
40
+ `no sparring "${slug}" (${file}); \`sparring.mjs list\` merges one recorded per repo`,
41
+ );
42
+ return JSON.parse(readFileSync(file, 'utf8'));
43
+ }
44
+
45
+ function writeSparringState(state, env) {
46
+ writeFileSync(
47
+ join(sparringDirOf(state.slug, env), SPARRING_FILE),
48
+ JSON.stringify(state, null, 2) + '\n',
49
+ );
50
+ }
51
+
52
+ /** Records (or re-stamps) the fight's chronicle link on the sparring. Returns the new state. */
53
+ export function recordSparringChronicle(
54
+ slug,
55
+ url,
56
+ publishedAt,
57
+ env = process.env,
58
+ ) {
59
+ const next = withChronicleUrl(
60
+ readSparringState(slug, env),
61
+ url,
62
+ publishedAt,
63
+ 'this fight',
64
+ );
65
+ writeSparringState(next, env);
66
+ return next;
67
+ }
68
+
69
+ /** Marks a milestone of the fight on the sparring, at this moment. Returns the new state. */
70
+ export function markSparring(slug, kind, note, env = process.env) {
71
+ const next = withMilestone(readSparringState(slug, env), kind, note);
72
+ writeSparringState(next, env);
73
+ return next;
74
+ }
75
+
76
+ const run = (bin, args, cwd) =>
77
+ execFileSync(bin, args, {
78
+ cwd,
79
+ encoding: 'utf8',
80
+ stdio: ['ignore', 'pipe', 'pipe'],
81
+ maxBuffer: 64 * 1024 * 1024,
82
+ });
83
+
84
+ function option(argv, name) {
85
+ const index = argv.indexOf(`--${name}`);
86
+ return index === -1 ? null : argv[index + 1];
87
+ }
88
+
89
+ /** Every bout opened `--from-sparring` this sparring, in the sparring's repos. */
90
+ export function boutsOfSparring(state, env = process.env) {
91
+ return (state.repos ?? []).flatMap((entry) =>
92
+ repoBouts(slugFromRemote(entry.repo), env)
93
+ .filter((bout) => bout.state.fromSparring === state.slug)
94
+ .map((bout) => ({ repo: entry.repo, ...bout })),
95
+ );
96
+ }
97
+
98
+ /** The sparring's own events: its state, feed, catalogs, git and GitHub. */
99
+ function sparringEvents(dir, state, argv, timeZone, notes) {
100
+ const read = (name) =>
101
+ existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
102
+ const catalogs = readdirSync(dir)
103
+ .filter((name) => /^blows-round-\d+\.md$/.test(name))
104
+ .sort((a, b) => parseInt(a.slice(12), 10) - parseInt(b.slice(12), 10))
105
+ .map((name) => parseBlows(read(name), name));
106
+ const tapes = tapeEvents(catalogs, {
107
+ openedAt: state.openedAt,
108
+ timeZone,
109
+ });
110
+ const events = [
111
+ ...stateEvents(state),
112
+ ...feedEvents(read(LIVE_LOG)),
113
+ ...tapes.events,
114
+ ];
115
+ if (!read(LIVE_LOG))
116
+ notes.push(
117
+ 'live.jsonl: no feed; repos added and preview looks come only from sparring.json (the last look per repo)',
118
+ );
119
+ const lastRound = catalogs.at(-1)?.round ?? 0;
120
+ if (lastRound > verdictsOf(state).length + 1)
121
+ notes.push(
122
+ `sparring.json records ${verdictsOf(state).length} verdict(s) but blows-round-${lastRound}.md exists: the earlier rounds have no recorded verdict`,
123
+ );
124
+ for (const entry of state.repos ?? []) {
125
+ if (!entry.worktree || !existsSync(entry.worktree)) {
126
+ notes.push(
127
+ `git: the worktree of ${entry.repo} is gone; its commits are not in the chronicle`,
128
+ );
129
+ continue;
130
+ }
131
+ events.push(
132
+ ...commitEvents(
133
+ run('git', [
134
+ '-C',
135
+ entry.worktree,
136
+ 'log',
137
+ '--no-merges',
138
+ `--since=${state.openedAt}`,
139
+ '--format=%H%x09%cI%x09%s',
140
+ 'HEAD',
141
+ ]),
142
+ ).map((event) => ({
143
+ ...event,
144
+ detail: `${entry.repo} · ${event.detail}`,
145
+ })),
146
+ );
147
+ }
148
+ const prs = (state.repos ?? []).filter((entry) => entry.pr);
149
+ if (argv.includes('--no-gh'))
150
+ notes.push('gh: not consulted; the PRs are dated only by the live feed');
151
+ if (!argv.includes('--no-gh'))
152
+ events.push(
153
+ ...prOpenedEvents(
154
+ prs.map((entry) => ({
155
+ repo: entry.repo,
156
+ number: entry.pr,
157
+ ...JSON.parse(
158
+ run('gh', [
159
+ 'pr',
160
+ 'view',
161
+ String(entry.pr),
162
+ '-R',
163
+ entry.repo,
164
+ '--json',
165
+ 'createdAt,url',
166
+ ]),
167
+ ),
168
+ })),
169
+ ),
170
+ );
171
+ return { events: dedupe(events), gaps: tapes.gaps, catalogs };
172
+ }
173
+
174
+ /**
175
+ * The whole fight from a sparring: its first and second falls and, as the third, every bout opened
176
+ * `--from-sparring` it. `collectBout` and `readExtra` come from play-by-play.mjs.
177
+ */
178
+ export function collectFight(slug, argv, { collectBout, readExtra }) {
179
+ const timezone =
180
+ option(argv, 'tz') ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
181
+ const dir = sparringDirOf(slug);
182
+ const state = readSparringState(slug);
183
+ const notes = [
184
+ `tapes: the time next to each Tape is read in ${timezone} (the judge's)`,
185
+ ];
186
+ const own = sparringEvents(dir, state, argv, timezone, notes);
187
+ const gaps = [...own.gaps];
188
+ const extraValue = option(argv, 'extra');
189
+ const extra = extraValue ? readExtra(extraValue) : { events: [], gaps: [] };
190
+ gaps.push(...extra.gaps);
191
+ const bouts = boutsOfSparring(state).map(({ repo, state: bout }) => {
192
+ const passed = ['--repo', repo, '--tz', timezone];
193
+ if (argv.includes('--no-gh')) passed.push('--no-gh');
194
+ return { repo, ...collectBout([String(bout.pr), ...passed]) };
195
+ });
196
+ for (const bout of bouts) {
197
+ gaps.push(...bout.gaps.map((gap) => ({ ...gap, pr: bout.pr })));
198
+ notes.push(...bout.notes.map((note) => `#${bout.pr}: ${note}`));
199
+ }
200
+ const boutEvents = bouts.flatMap((bout) =>
201
+ bout.events.map((event) => ({ ...event, fall: 3, pr: bout.pr })),
202
+ );
203
+ const sliced = sliceFight(
204
+ [...own.events, ...extra.events, ...boutEvents],
205
+ bouts.map((bout) => ({ pr: bout.pr, rounds: bout.rounds })),
206
+ );
207
+ const events = sliced.events.map((event) => ({
208
+ ...event,
209
+ local: localTime(event.at, timezone),
210
+ day: localDay(event.at, timezone),
211
+ }));
212
+ return {
213
+ sparring: slug,
214
+ sparringDir: dir,
215
+ page: join(dir, 'play-by-play.html'),
216
+ playByPlayUrl: state.playByPlayUrl ?? null,
217
+ generatedAt: new Date().toISOString(),
218
+ timezone,
219
+ hypothesis: state.hypothesis,
220
+ proof: state.proof,
221
+ verdict: state.verdict ?? null,
222
+ repos: (state.repos ?? []).map(({ repo, pr, url }) => ({ repo, pr, url })),
223
+ bouts: bouts.map((bout) => ({
224
+ repo: bout.repo,
225
+ pr: bout.pr,
226
+ boutDir: bout.boutDir,
227
+ opened: bout.opened,
228
+ copilotOnHead: bout.copilotOnHead,
229
+ })),
230
+ falls: sliced.falls,
231
+ rounds: sliced.rounds,
232
+ events,
233
+ score: score(events, sliced.rounds),
234
+ catalogs: own.catalogs,
235
+ gaps,
236
+ notes,
237
+ };
238
+ }
@@ -2,6 +2,10 @@
2
2
  // The durable state of a bout and its judges card (logbook and hand-off). Zero-dependency.
3
3
  //
4
4
  // node judges-card.mjs open --pr <n> --worktree <abs> --branch <b> --base <base> [--venue <name>]
5
+ // [--from-sparring <slug>] the bout of a sparring's KO: records
6
+ // `fromSparring` and marks the hand-off on the sparring (third fall);
7
+ // refuses anything but a KO, warns when neither the vestidor nor
8
+ // «sin equipo» (no-team) was marked
5
9
  // node judges-card.mjs summary <pr> everything a new session needs to resume
6
10
  // node judges-card.mjs log <pr> decision|rebound <text>
7
11
  // node judges-card.mjs did <pr> <text> a "What I did" line in the judges card, stamped by the clock
@@ -14,7 +18,8 @@
14
18
  // the contract files, so mask-guard lets that commit land
15
19
  // node judges-card.mjs play-by-play-url <pr> <url>
16
20
  // after every publish: records the chronicle's stable
17
- // Artifact link once and stamps playByPlay.publishedAt
21
+ // Artifact link once and stamps playByPlay.publishedAt;
22
+ // a bout from a sparring shares the fight's one link
18
23
  // node judges-card.mjs title-defense <pr> --until-round <k> | --clear
19
24
  // lets mask-guard allow Copilot re-requests and thread
20
25
  // replies/resolution for this PR while title-defense runs;
@@ -54,7 +59,13 @@ import {
54
59
  unshown,
55
60
  withSectionLine,
56
61
  } from './findings.mjs';
62
+ import {
63
+ markSparring,
64
+ readSparringState,
65
+ recordSparringChronicle,
66
+ } from './fight.mjs';
57
67
  import { record } from './live.mjs';
68
+ import { handOffWarning, withChronicleUrl } from './sparring-chronicle.mjs';
58
69
  import { DEFAULT_VENUE } from './venues.mjs';
59
70
 
60
71
  export const TEMPLATES = join(RING_ROOT, 'skills', 'referee', 'templates');
@@ -137,11 +148,13 @@ function open(argv) {
137
148
  branch,
138
149
  base,
139
150
  venue = DEFAULT_VENUE,
151
+ 'from-sparring': fromSparring,
140
152
  } = options(argv.filter((arg) => arg !== '--allow-empty-contract'));
141
153
  if (!pr || !worktree || !branch || !base)
142
154
  throw new Error(
143
- 'usage: open --pr <n> --worktree <abs path> --branch <head branch> --base <stack base> [--venue <name>] [--allow-empty-contract]',
155
+ 'usage: open --pr <n> --worktree <abs path> --branch <head branch> --base <stack base> [--venue <name>] [--from-sparring <slug>] [--allow-empty-contract]',
144
156
  );
157
+ if (fromSparring) checkKo(fromSparring);
145
158
  if (!isAbsolute(worktree))
146
159
  throw new Error('--worktree must be an absolute path');
147
160
  if (!VENUE_NAMES.includes(venue))
@@ -162,8 +175,11 @@ function open(argv) {
162
175
  lastReviewed: git(worktree, ['rev-parse', 'HEAD']),
163
176
  active: true,
164
177
  venue,
178
+ ...(fromSparring ? { fromSparring } : {}),
165
179
  };
166
180
  writeState(dir, state);
181
+ if (fromSparring)
182
+ markSparring(fromSparring, 'referee', `#${pr} ${repoSlug(worktree)}`);
167
183
  writeFileSync(
168
184
  join(dir, 'decisions.md'),
169
185
  `# Decisions for #${pr}\n\nOne line each: id · date · who · decision · what it affects.\n\n`,
@@ -465,27 +481,55 @@ function titleDefense([pr, ...argv]) {
465
481
 
466
482
  /**
467
483
  * Records the play-by-play page's link, and stamps `playByPlay.publishedAt` on every publish to it.
468
- * The same link again is a republish; a different one is refused. PURE.
484
+ * The same link again is a republish; a different one is refused. A bout from a sparring
485
+ * (`fightUrl`, the sparring's link) shares the fight's one link. PURE.
469
486
  */
470
487
  export function withPlayByPlayUrl(
471
488
  state,
472
489
  url,
473
490
  publishedAt = new Date().toISOString(),
491
+ fightUrl = null,
474
492
  ) {
475
- if (!/^https:\/\/claude\.ai\//.test(String(url)))
476
- throw new Error('the play-by-play link must be a claude.ai artifact URL');
477
- if (state.playByPlayUrl && state.playByPlayUrl !== url)
493
+ const seeded = state.playByPlayUrl ?? fightUrl;
494
+ return withChronicleUrl(
495
+ seeded ? { ...state, playByPlayUrl: seeded } : state,
496
+ url,
497
+ publishedAt,
498
+ state.playByPlayUrl ? 'this bout' : 'this fight',
499
+ );
500
+ }
501
+
502
+ /**
503
+ * A bout opens from a sparring only once the judge called it KO. It warns, without refusing, when
504
+ * the KO skipped both the vestidor and «sin equipo».
505
+ */
506
+ function checkKo(slug) {
507
+ const state = readSparringState(slug);
508
+ const call = state.verdict?.call ?? 'no verdict';
509
+ if (call !== 'ko')
478
510
  throw new Error(
479
- `this bout already has its chronicle at ${state.playByPlayUrl}; republish to that link instead of creating another`,
511
+ `sparring "${slug}" is not a KO (${call}): only a KO hands its branch to a bout`,
480
512
  );
481
- return { ...state, playByPlayUrl: url, playByPlay: { publishedAt } };
513
+ const warning = handOffWarning(state);
514
+ if (warning) console.error(`warning: ${warning}`);
482
515
  }
483
516
 
484
517
  function playByPlayUrl([pr, url]) {
485
518
  if (!pr || !url) throw new Error('usage: play-by-play-url <pr> <url>');
486
519
  const dir = currentDir(pr);
487
- const next = withPlayByPlayUrl(readState(dir), url);
520
+ const state = readState(dir);
521
+ const fight = state.fromSparring
522
+ ? readSparringState(state.fromSparring)
523
+ : null;
524
+ const next = withPlayByPlayUrl(
525
+ state,
526
+ url,
527
+ undefined,
528
+ fight?.playByPlayUrl ?? null,
529
+ );
488
530
  writeState(dir, next);
531
+ if (fight)
532
+ recordSparringChronicle(fight.slug, url, next.playByPlay.publishedAt);
489
533
  record(dir, {
490
534
  actor: 'arbitro',
491
535
  state: 'done',
@@ -2,10 +2,10 @@
2
2
  // Hook on PreToolUse / PostToolUse / PostToolUseFailure (Agent, AskUserQuestion, Bash) and
3
3
  // SubagentStop: feeds the bout's live.jsonl so Ringside shows who is acting. Zero-dependency.
4
4
  //
5
- // - Agent: a subagent of type ring:wrestler or ring:cornerman, or whose description starts with
6
- // "Wrestler:" / "Cornerman:" (also "Luchador:" / "Esquinero:"), marks the luchador or the
7
- // esquinero active, labelled with the first three words of the description. A background agent
8
- // stays active until its SubagentStop.
5
+ // - Agent: a subagent of type ring:wrestler, ring:cornerman or ring:rudo, or whose description
6
+ // starts with "Wrestler:" / "Cornerman:" / "Rudo:" (also "Luchador:" / "Esquinero:"), marks the
7
+ // luchador, the esquinero or the rudo active, labelled with the first three words of the
8
+ // description. A background agent stays active until its SubagentStop.
9
9
  // - AskUserQuestion: the juez waits on the human until the question is answered.
10
10
  // - Bash: a command that EXECUTES what a declared monitor matches (`.claude/ring.json` → monitors;
11
11
  // heredoc bodies and quoted text don't count) marks it active until the command ends, labelled
@@ -21,6 +21,11 @@
21
21
  // so a session that only looks at a bout never shows up as its referee. Subagents share their parent's session_id, so the wrestler and the cornermen of a
22
22
  // registered referee count too. Any other session working in the same repo is ignored.
23
23
  //
24
+ // A session with no bout of its own feeds the open sparring it registered instead
25
+ // (`sparrings/<slug>/live.jsonl`; `sparring.mjs` registers the session that runs it, except `list`):
26
+ // only actor events (rudo, juez), never a repo's monitors. A sparring script never registers the
27
+ // session in a bout.
28
+ //
24
29
  // It ALWAYS exits 0 and prints nothing: the feed is decoration and must never block or slow a tool.
25
30
  import { readFileSync } from 'node:fs';
26
31
  import { join } from 'node:path';
@@ -28,6 +33,7 @@ import { join } from 'node:path';
28
33
  import { isMain, loadConfig, projectDir } from './config.mjs';
29
34
  import {
30
35
  findBout,
36
+ findSparring,
31
37
  LIVE_LOG,
32
38
  record,
33
39
  registerSession,
@@ -42,14 +48,21 @@ import {
42
48
  } from './monitors.mjs';
43
49
 
44
50
  const AGENT_TOOLS = ['Agent', 'Task'];
45
- const TYPES = { 'ring:wrestler': 'luchador', 'ring:cornerman': 'esquinero' };
46
- const PREFIX = /^\s*(wrestler|luchador|cornerman|esquinero)\s*[:·—-]\s*/i;
51
+ const TYPES = {
52
+ 'ring:wrestler': 'luchador',
53
+ 'ring:cornerman': 'esquinero',
54
+ 'ring:rudo': 'rudo',
55
+ };
56
+ const PREFIX = /^\s*(wrestler|luchador|cornerman|esquinero|rudo)\s*[:·—-]\s*/i;
47
57
  const PREFIX_ACTOR = {
48
58
  wrestler: 'luchador',
49
59
  luchador: 'luchador',
50
60
  cornerman: 'esquinero',
51
61
  esquinero: 'esquinero',
62
+ rudo: 'rudo',
52
63
  };
64
+ /** The actors an Agent call marks: what a SubagentStop may close. */
65
+ const AGENT_ACTORS = Object.values(PREFIX_ACTOR);
53
66
 
54
67
  /** The ring actor of an Agent call, or null when it is not one of the ring's. PURE. */
55
68
  export function classifyAgent(input) {
@@ -162,7 +175,7 @@ export function stoppedAgent(lines, agentId, agentType) {
162
175
  for (const line of lines) {
163
176
  try {
164
177
  const event = JSON.parse(line);
165
- if (event.id && ['luchador', 'esquinero'].includes(event.actor))
178
+ if (event.id && AGENT_ACTORS.includes(event.actor))
166
179
  latest.set(event.id, event);
167
180
  } catch {
168
181
  // A torn line is skipped.
@@ -245,9 +258,10 @@ function eventsFor(input) {
245
258
  }
246
259
 
247
260
  // Only a segment that runs node acts: naming a ring script (`git add …/scripts/x.mjs`, `cat`) does not.
261
+ // A sparring script acts on a sparring, never on a bout: it registers itself there.
248
262
  const RUNS_NODE = /(?:^|\s)node(?:\s|$)/;
249
263
  const READS_ONLY =
250
- /\/scripts\/(?:config\.mjs|judges-card\.mjs["']?\s+(?:list|path)(?![\w-]))/;
264
+ /\/scripts\/(?:config\.mjs|sparring\.mjs|judges-card\.mjs["']?\s+(?:list|path)(?![\w-]))/;
251
265
 
252
266
  /** Does a command run a ring script that acts on the bout, not one that only reads it? PURE. */
253
267
  export const actsOnBout = (command) =>
@@ -266,17 +280,30 @@ const registersSession = (input) =>
266
280
  input.hook_event_name === 'PreToolUse' &&
267
281
  actsOnBout(input.tool_input?.command);
268
282
 
283
+ /**
284
+ * Where this session's events go: its bout when it is one of the bout's sessions, else the open
285
+ * sparring it registered. Null when it has neither.
286
+ */
287
+ function feedOf(input, registering) {
288
+ const hint = input.tool_input?.description;
289
+ const bout = findBout({ cwd: input.cwd, hint });
290
+ if (bout && registering)
291
+ registerSession(bout, input.session_id, input.agent_type ?? 'referee');
292
+ if (bout && sessionRegistered(bout, input.session_id))
293
+ return { dir: bout, isBout: true };
294
+ const sparring = findSparring({ sessionId: input.session_id, hint });
295
+ return sparring ? { dir: sparring, isBout: false } : null;
296
+ }
297
+
269
298
  export function handle(input) {
270
299
  const registering = registersSession(input);
271
300
  const make = eventsFor(input);
272
301
  if (!make && !registering) return;
273
- const dir = findBout({ cwd: input.cwd, hint: input.tool_input?.description });
274
- if (!dir) return;
275
- if (registering)
276
- registerSession(dir, input.session_id, input.agent_type ?? 'referee');
277
- if (!make || !sessionRegistered(dir, input.session_id)) return;
278
- for (const event of make(dir))
279
- record(dir, { ...event, session: input.session_id });
302
+ const feed = feedOf(input, registering);
303
+ if (!make || !feed) return;
304
+ // A sparring carries actors only: monitors belong to a repo, not to a sparring.
305
+ for (const event of make(feed.dir).filter((e) => feed.isBout || e.actor))
306
+ record(feed.dir, { ...event, session: input.session_id });
280
307
  }
281
308
 
282
309
  if (isMain(import.meta.url)) {