@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.
- package/README.md +38 -5
- package/fonts/battle-arena-icons/BattleArenaIcons.ttf +0 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/icons.generated.ts +2 -0
- package/plugin/hooks/pane.tsx +16 -2
- package/plugin/hooks/register.tsx +62 -33
- package/plugin/hooks/ring-io.ts +59 -1
- package/plugin/hooks/ring-pane.tsx +125 -35
- package/plugin/hooks/ring.ts +73 -38
- package/plugin/hooks/sparring.ts +139 -0
- package/plugin/icons/rudo-amber-2x1.png +0 -0
- package/plugin/icons/rudo-amber-4x2.png +0 -0
- package/plugin/icons/rudo-amber-5x2.png +0 -0
- package/plugin/icons/rudo-amber-6x2.png +0 -0
- package/plugin/icons/rudo-grey-2x1.png +0 -0
- package/plugin/icons/rudo-grey-4x2.png +0 -0
- package/plugin/icons/rudo-grey-5x2.png +0 -0
- package/plugin/icons/rudo-grey-6x2.png +0 -0
- package/plugin/icons/rudo-pink-2x1.png +0 -0
- package/plugin/icons/rudo-pink-4x2.png +0 -0
- package/plugin/icons/rudo-pink-5x2.png +0 -0
- package/plugin/icons/rudo-pink-6x2.png +0 -0
- package/plugin/icons/rudo-pink-dim-2x1.png +0 -0
- package/plugin/icons/rudo-pink-dim-4x2.png +0 -0
- package/plugin/icons/rudo-pink-dim-5x2.png +0 -0
- package/plugin/icons/rudo-pink-dim-6x2.png +0 -0
- package/plugin/icons/rudo-red-2x1.png +0 -0
- package/plugin/icons/rudo-red-4x2.png +0 -0
- package/plugin/icons/rudo-red-5x2.png +0 -0
- package/plugin/icons/rudo-red-6x2.png +0 -0
- package/plugin/tests/ring-pane.test.tsx +154 -0
- package/plugin/tests/ring.test.ts +13 -4
- package/plugin/tests/sparring.test.ts +204 -0
- package/plugin/types/index.d.ts +25 -1
- package/ring/.claude-plugin/plugin.json +1 -1
- package/ring/agents/rudo.md +95 -0
- package/ring/scripts/chronicle.mjs +24 -0
- package/ring/scripts/config.mjs +47 -1
- package/ring/scripts/fight.mjs +238 -0
- package/ring/scripts/judges-card.mjs +53 -9
- package/ring/scripts/live-hook.mjs +42 -15
- package/ring/scripts/live.mjs +96 -1
- package/ring/scripts/mask-guard.mjs +184 -6
- package/ring/scripts/monitors.mjs +2 -1
- package/ring/scripts/play-by-play.mjs +39 -9
- package/ring/scripts/round-bell.mjs +10 -6
- package/ring/scripts/sparring-chronicle.mjs +470 -0
- package/ring/scripts/sparring.mjs +1161 -0
- package/ring/skills/play-by-play/SKILL.md +49 -3
- package/ring/skills/play-by-play/templates/chronicle.html +7 -0
- package/ring/skills/referee/SKILL.md +46 -2
- package/ring/skills/sparring/SKILL.md +408 -0
- package/ring/skills/sparring/references/host-signal.md +86 -0
- package/ring/skills/sparring/references/lessons.md +50 -0
- package/ring/skills/sparring/templates/pr-body.md +34 -0
- package/ring/skills/sparring/templates/rudo-brief.md +47 -0
- package/ring/skills/sparring/templates/sparring-card.md +33 -0
- package/ring/skills/weigh-in/SKILL.md +21 -3
package/ring/scripts/config.mjs
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
476
|
-
|
|
477
|
-
|
|
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
|
-
`
|
|
511
|
+
`sparring "${slug}" is not a KO (${call}): only a KO hands its branch to a bout`,
|
|
480
512
|
);
|
|
481
|
-
|
|
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
|
|
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:
|
|
6
|
-
// "Wrestler:" / "Cornerman:" (also "Luchador:" / "Esquinero:"), marks the
|
|
7
|
-
// esquinero active, labelled with the first three words of the
|
|
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 = {
|
|
46
|
-
|
|
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 &&
|
|
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
|
|
274
|
-
if (!
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
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)) {
|