@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
|
@@ -0,0 +1,1161 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Sparring drafts: a quick, low-quality draft that only proves whether a feature is worth building
|
|
3
|
+
// (/ring:sparring). One sparring may span several repos (a back and its front): one hypothesis,
|
|
4
|
+
// one card and one verdict, a worktree, a branch and a PR per repo. Zero-dependency.
|
|
5
|
+
//
|
|
6
|
+
// node sparring.mjs open <slug> --worktree <abs> --base <branch> --hypothesis <text>
|
|
7
|
+
// --proof <text> [--credentials NAME,NAME]
|
|
8
|
+
// node sparring.mjs add-repo <slug> --worktree <abs> --base <branch>
|
|
9
|
+
// node sparring.mjs pr <slug> --title <text> [--repo <owner/repo>] [--how-to-test <text>]
|
|
10
|
+
// [--shortcuts <text>] [--dry-run]
|
|
11
|
+
// node sparring.mjs preview <slug> [--repo <owner/repo>] [--wait]
|
|
12
|
+
// node sparring.mjs verdict <slug> ko|rematch|discard --note <text>
|
|
13
|
+
// node sparring.mjs vestidor <slug>
|
|
14
|
+
// node sparring.mjs mark <slug> vestidor|defense|bell|mask-kept|mask-lost|no-team|referee
|
|
15
|
+
// [--note <text>]
|
|
16
|
+
// node sparring.mjs play-by-play-url <slug> <url>
|
|
17
|
+
// node sparring.mjs promote <slug> [--repo <owner/repo>] [--title <text>] [--dry-run]
|
|
18
|
+
// node sparring.mjs list
|
|
19
|
+
//
|
|
20
|
+
// `mark` stamps a moment of the fight (two out of three falls) at the time it happens, so the
|
|
21
|
+
// chronicle (play-by-play --sparring) has it with its real time; `play-by-play-url` records the
|
|
22
|
+
// fight's one chronicle link and stamps every publish to it. `vestidor`, only after a KO, runs each
|
|
23
|
+
// repo's own whole-repo gates (typecheck, lint, build; never test, which takes paths) in its
|
|
24
|
+
// worktree, one at a time through the machine lock, and stamps the vestidor only when all passed.
|
|
25
|
+
//
|
|
26
|
+
// State lives in `<ring home>/sparrings/<slug>/sparring.json`, never `state.json`: a sparring is not
|
|
27
|
+
// a bout, so mask-guard's bout rules (review cap, approved pushes) never hold the rudo back. It
|
|
28
|
+
// belongs to no repo: each repo of the sparring is named by its worktree, and its config, labels
|
|
29
|
+
// and PR come from there, never from the session's checkout. The PR is the event: it is opened as a
|
|
30
|
+
// draft on `sparring/<slug>` with the sparring and do-not-merge labels, and the host's CI decides
|
|
31
|
+
// what to do with it (deploy a preview or nothing). The ring only reads the preview back from the
|
|
32
|
+
// GitHub Deployments API. Credentials are handled by NAME only: the host resolves their values.
|
|
33
|
+
//
|
|
34
|
+
// A KO is promoted, never merged: `promote` creates `release/<slug>` from the repo's base and
|
|
35
|
+
// `mainevent/<slug>` from the sparring tip, and opens the deliverable PR mainevent → release as a
|
|
36
|
+
// draft. The ring bout cleans `mainevent/<slug>` (no line ceiling there: the round-bells are what
|
|
37
|
+
// the human reads); the sparring and its PR stay do-not-merge as the evidence of what the KO saw.
|
|
38
|
+
// It is the only way a sparring's code leaves `sparring/*`: mask-guard still refuses that push by
|
|
39
|
+
// hand, and `promote` refuses unless the verdict is `ko`.
|
|
40
|
+
//
|
|
41
|
+
// Ringside shows each open sparring like a bout: every command but `list` registers the session
|
|
42
|
+
// that runs it (CLAUDE_CODE_SESSION_ID → `sessions`, so live-hook feeds this sparring from it) and
|
|
43
|
+
// writes what it did to `<sparring dir>/live.jsonl` (árbitro; the verdict is the juez's).
|
|
44
|
+
import { execFileSync, spawnSync } from 'node:child_process';
|
|
45
|
+
import {
|
|
46
|
+
existsSync,
|
|
47
|
+
lstatSync,
|
|
48
|
+
mkdirSync,
|
|
49
|
+
readdirSync,
|
|
50
|
+
readFileSync,
|
|
51
|
+
renameSync,
|
|
52
|
+
statSync,
|
|
53
|
+
symlinkSync,
|
|
54
|
+
writeFileSync,
|
|
55
|
+
} from 'node:fs';
|
|
56
|
+
import { dirname, isAbsolute, join } from 'node:path';
|
|
57
|
+
import { fileURLToPath } from 'node:url';
|
|
58
|
+
|
|
59
|
+
import {
|
|
60
|
+
DEFAULTS,
|
|
61
|
+
DO_NOT_MERGE,
|
|
62
|
+
isMain,
|
|
63
|
+
loadConfig,
|
|
64
|
+
RING_ROOT,
|
|
65
|
+
repoSlug,
|
|
66
|
+
ringHome,
|
|
67
|
+
SPARRING_PREFIX,
|
|
68
|
+
} from './config.mjs';
|
|
69
|
+
import { render } from './judges-card.mjs';
|
|
70
|
+
import {
|
|
71
|
+
FINAL_VERDICTS,
|
|
72
|
+
record,
|
|
73
|
+
registerSparringSession,
|
|
74
|
+
SPARRING_FILE,
|
|
75
|
+
sparringsHome,
|
|
76
|
+
} from './live.mjs';
|
|
77
|
+
import { labelExists } from './round-bell.mjs';
|
|
78
|
+
import {
|
|
79
|
+
checkMilestone,
|
|
80
|
+
withChronicleUrl,
|
|
81
|
+
withMilestone,
|
|
82
|
+
} from './sparring-chronicle.mjs';
|
|
83
|
+
|
|
84
|
+
export const TEMPLATES = join(RING_ROOT, 'skills', 'sparring', 'templates');
|
|
85
|
+
export const VERDICTS = ['ko', 'rematch', 'discard'];
|
|
86
|
+
export const STATE_FILE = SPARRING_FILE;
|
|
87
|
+
|
|
88
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
89
|
+
const SLUG = /^[a-z0-9][a-z0-9-]{0,39}$/;
|
|
90
|
+
const CREDENTIAL = /^[A-Z][A-Z0-9_]*$/;
|
|
91
|
+
const TERMINAL = ['success', 'failure', 'error', 'inactive'];
|
|
92
|
+
const POLL_SECONDS = 30;
|
|
93
|
+
|
|
94
|
+
export const sparringBranch = (slug) => `${SPARRING_PREFIX}${slug}`;
|
|
95
|
+
|
|
96
|
+
/** The line of a sibling repo in a sparring PR body, before and after its PR exists. PURE. */
|
|
97
|
+
export const siblingLine = (repo, url) =>
|
|
98
|
+
`- \`${repo}\`: ${url ?? '_PR not opened yet_'}`;
|
|
99
|
+
|
|
100
|
+
/** A PR body with `repo`'s pending sibling line pointing at `url`; unchanged when absent. PURE. */
|
|
101
|
+
export const withSiblingUrl = (body, repo, url) =>
|
|
102
|
+
body.replace(siblingLine(repo, null), siblingLine(repo, url));
|
|
103
|
+
|
|
104
|
+
/** The wait before the next preview poll: never past the deadline, so --wait stays bounded. PURE. */
|
|
105
|
+
export const nextPollSeconds = (deadline, now) =>
|
|
106
|
+
Math.max(0, Math.min(POLL_SECONDS, (deadline - now) / 1000));
|
|
107
|
+
export const MAINEVENT_PREFIX = 'mainevent/';
|
|
108
|
+
export const RELEASE_PREFIX = 'release/';
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* What `promote` does for one repo, given the branches its origin already has. PURE. Throws unless
|
|
112
|
+
* the verdict is `ko`, and when the main event already exists (a second promote would overwrite
|
|
113
|
+
* the cleanup). The release is reused when it exists: back and front may share the slug, never
|
|
114
|
+
* the repo, so each repo gets its own.
|
|
115
|
+
*/
|
|
116
|
+
export function promotionPlan(state, entry, remoteHeads) {
|
|
117
|
+
if (state.verdict?.call !== 'ko')
|
|
118
|
+
throw new Error(
|
|
119
|
+
`sparring "${state.slug}" is not a KO (${state.verdict?.call ?? 'no verdict'}): only a KO is promoted`,
|
|
120
|
+
);
|
|
121
|
+
const release = `${RELEASE_PREFIX}${state.slug}`;
|
|
122
|
+
const mainevent = `${MAINEVENT_PREFIX}${state.slug}`;
|
|
123
|
+
if (remoteHeads.includes(mainevent))
|
|
124
|
+
throw new Error(
|
|
125
|
+
`${entry.repo} already has ${mainevent}: the sparring was promoted; the bout works there`,
|
|
126
|
+
);
|
|
127
|
+
return {
|
|
128
|
+
release,
|
|
129
|
+
mainevent,
|
|
130
|
+
pushes: [
|
|
131
|
+
...(remoteHeads.includes(release)
|
|
132
|
+
? []
|
|
133
|
+
: [`origin/${entry.base}:refs/heads/${release}`]),
|
|
134
|
+
`origin/${entry.branch}:refs/heads/${mainevent}`,
|
|
135
|
+
],
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** The `gh pr create` arguments of the deliverable PR. PURE. */
|
|
140
|
+
export const mainEventArgs = ({ title, release, mainevent, bodyFile }) => [
|
|
141
|
+
'pr',
|
|
142
|
+
'create',
|
|
143
|
+
'--draft',
|
|
144
|
+
'--base',
|
|
145
|
+
release,
|
|
146
|
+
'--head',
|
|
147
|
+
mainevent,
|
|
148
|
+
'--title',
|
|
149
|
+
title,
|
|
150
|
+
'--body-file',
|
|
151
|
+
bodyFile,
|
|
152
|
+
];
|
|
153
|
+
|
|
154
|
+
/** Throws unless `slug` is a short kebab-case name. PURE. */
|
|
155
|
+
export function checkSlug(slug) {
|
|
156
|
+
if (!SLUG.test(slug ?? ''))
|
|
157
|
+
throw new Error(
|
|
158
|
+
`"${slug}" is not a sparring slug: lowercase letters, digits and dashes, up to 40`,
|
|
159
|
+
);
|
|
160
|
+
return slug;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* `"KAPSO_API_KEY, KAPSO_WEBHOOK_SECRET"` → the names. PURE. Anything that is not an env-var-like
|
|
165
|
+
* name is refused, so a value pasted by mistake (`KEY=abc`, a token) never reaches a PR body.
|
|
166
|
+
*/
|
|
167
|
+
export function parseCredentials(text) {
|
|
168
|
+
const names = String(text ?? '')
|
|
169
|
+
.split(/[\s,]+/)
|
|
170
|
+
.filter(Boolean);
|
|
171
|
+
const bad = names.filter((name) => !CREDENTIAL.test(name));
|
|
172
|
+
if (bad.length)
|
|
173
|
+
throw new Error(
|
|
174
|
+
`credentials are declared by NAME only (UPPER_SNAKE_CASE); refusing ${bad.length} value(s) that are not a name. Never pass a secret value to the ring.`,
|
|
175
|
+
);
|
|
176
|
+
return [...new Set(names)];
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** The block the host's CI reads to know which secrets the preview needs. PURE. */
|
|
180
|
+
export function credentialsBlock(names) {
|
|
181
|
+
if (!names.length) return '';
|
|
182
|
+
return ['<!-- sparring:credentials', ...names, '-->'].join('\n');
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** The names a PR body declares, back from its block. PURE. */
|
|
186
|
+
export function declaredCredentials(body) {
|
|
187
|
+
const block = /<!-- sparring:credentials\n([\s\S]*?)-->/.exec(body ?? '');
|
|
188
|
+
return block ? parseCredentials(block[1]) : [];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** The fixed warning: credentials used by a sparring are assumed rotated. PURE. */
|
|
192
|
+
export function credentialWarning(names) {
|
|
193
|
+
if (!names.length) return '_This sparring declares no credentials._';
|
|
194
|
+
const list = names.map((name) => `\`${name}\``).join(', ');
|
|
195
|
+
return `> ⚠️ This sparring used sandbox credentials (${list}). They are assumed rotated.`;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The `gh pr create` arguments of a sparring PR. PURE. */
|
|
199
|
+
export function createArgs({ title, base, head, bodyFile, label }) {
|
|
200
|
+
return [
|
|
201
|
+
'pr',
|
|
202
|
+
'create',
|
|
203
|
+
'--draft',
|
|
204
|
+
'--base',
|
|
205
|
+
base,
|
|
206
|
+
'--head',
|
|
207
|
+
head,
|
|
208
|
+
'--title',
|
|
209
|
+
`[sparring] ${title} (do not merge)`,
|
|
210
|
+
'--body-file',
|
|
211
|
+
bodyFile,
|
|
212
|
+
'--label',
|
|
213
|
+
label,
|
|
214
|
+
'--label',
|
|
215
|
+
DO_NOT_MERGE,
|
|
216
|
+
];
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* The preview of the newest deployment, from its statuses (newest first, as GitHub returns them).
|
|
221
|
+
* PURE. Returns { state, url, done }: `done` once the state is terminal.
|
|
222
|
+
*/
|
|
223
|
+
export function previewOf(statuses) {
|
|
224
|
+
const latest = statuses?.[0];
|
|
225
|
+
if (!latest) return { state: 'pending', url: null, done: false };
|
|
226
|
+
const url = latest.environment_url || latest.target_url || null;
|
|
227
|
+
return {
|
|
228
|
+
state: latest.state,
|
|
229
|
+
url: latest.state === 'success' ? url : null,
|
|
230
|
+
done: TERMINAL.includes(latest.state),
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Whole days between two ISO dates. PURE. */
|
|
235
|
+
export const ageDays = (from, now) =>
|
|
236
|
+
Math.floor((Date.parse(now) - Date.parse(from)) / 86_400_000);
|
|
237
|
+
|
|
238
|
+
/** A sparring with no verdict after `staleDays` is stale. PURE. */
|
|
239
|
+
export const isStale = (state, now, staleDays) =>
|
|
240
|
+
!state.verdict && ageDays(state.openedAt, now) >= staleDays;
|
|
241
|
+
|
|
242
|
+
/** One repo of a sparring, as `open` and `add-repo` record it. PURE. */
|
|
243
|
+
export const repoEntry = ({ repo, worktree, base, slug }) => ({
|
|
244
|
+
repo,
|
|
245
|
+
worktree,
|
|
246
|
+
base,
|
|
247
|
+
branch: sparringBranch(slug),
|
|
248
|
+
pr: null,
|
|
249
|
+
url: null,
|
|
250
|
+
preview: null,
|
|
251
|
+
shortcuts: null,
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* The repo of a sparring a command acts on. PURE. `key` is `owner/repo` or a worktree path; with a
|
|
256
|
+
* single repo it may be omitted. Throws, naming the choices, when it is missing or unknown.
|
|
257
|
+
*/
|
|
258
|
+
export function pickRepo(state, key) {
|
|
259
|
+
const names = state.repos.map((entry) => entry.repo).join(', ');
|
|
260
|
+
if (!key && state.repos.length === 1) return state.repos[0];
|
|
261
|
+
if (!key)
|
|
262
|
+
throw new Error(`this sparring spans ${names}: say which with --repo`);
|
|
263
|
+
const entry = state.repos.find((item) =>
|
|
264
|
+
[item.repo, item.worktree].includes(key),
|
|
265
|
+
);
|
|
266
|
+
if (!entry)
|
|
267
|
+
throw new Error(`"${key}" is not a repo of this sparring (${names})`);
|
|
268
|
+
return entry;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Sparrings recorded before they spanned repos (one `sparring.json` per repo, flat fields) → one
|
|
273
|
+
* sparring with a repo each. PURE. `legacy` is [{ repo, state }]; the earliest one gives the
|
|
274
|
+
* hypothesis, the proof and the date, credentials are the union, and a verdict survives if any
|
|
275
|
+
* repo had one.
|
|
276
|
+
*/
|
|
277
|
+
export function mergeLegacy(legacy) {
|
|
278
|
+
const sorted = [...legacy].sort((a, b) =>
|
|
279
|
+
a.state.openedAt.localeCompare(b.state.openedAt),
|
|
280
|
+
);
|
|
281
|
+
const first = sorted[0].state;
|
|
282
|
+
return {
|
|
283
|
+
slug: first.slug,
|
|
284
|
+
hypothesis: first.hypothesis,
|
|
285
|
+
proof: first.proof,
|
|
286
|
+
credentials: [
|
|
287
|
+
...new Set(sorted.flatMap(({ state }) => state.credentials ?? [])),
|
|
288
|
+
],
|
|
289
|
+
openedAt: first.openedAt,
|
|
290
|
+
verdict: sorted.map(({ state }) => state.verdict).find(Boolean) ?? null,
|
|
291
|
+
repos: sorted.map(({ repo, state }) => ({
|
|
292
|
+
repo,
|
|
293
|
+
worktree: state.worktree,
|
|
294
|
+
base: state.base,
|
|
295
|
+
branch: state.branch,
|
|
296
|
+
pr: state.pr ?? null,
|
|
297
|
+
url: state.url ?? null,
|
|
298
|
+
preview: state.preview ?? null,
|
|
299
|
+
shortcuts: state.shortcuts ?? null,
|
|
300
|
+
})),
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
function options(argv) {
|
|
305
|
+
const flags = {};
|
|
306
|
+
const positional = [];
|
|
307
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
308
|
+
if (['--dry-run', '--wait'].includes(argv[i])) {
|
|
309
|
+
flags[argv[i].slice(2)] = true;
|
|
310
|
+
continue;
|
|
311
|
+
}
|
|
312
|
+
if (argv[i].startsWith('--')) {
|
|
313
|
+
flags[argv[i].slice(2)] = argv[i + 1];
|
|
314
|
+
i += 1;
|
|
315
|
+
continue;
|
|
316
|
+
}
|
|
317
|
+
positional.push(argv[i]);
|
|
318
|
+
}
|
|
319
|
+
return { flags, positional };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
const exec = (bin, args, cwd) =>
|
|
323
|
+
execFileSync(bin, args, {
|
|
324
|
+
cwd,
|
|
325
|
+
encoding: 'utf8',
|
|
326
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
327
|
+
}).trim();
|
|
328
|
+
|
|
329
|
+
const dirOf = (slug) => join(sparringsHome(), checkSlug(slug));
|
|
330
|
+
|
|
331
|
+
/** Appends an event to the sparring's live feed. Best effort, like every feed writer. */
|
|
332
|
+
const feed = (slug, raw) => record(dirOf(slug), raw);
|
|
333
|
+
const refereeDid = (slug, label, extra = {}) =>
|
|
334
|
+
feed(slug, { actor: 'arbitro', state: 'done', label, ...extra });
|
|
335
|
+
const fileOf = (slug) => join(dirOf(slug), STATE_FILE);
|
|
336
|
+
|
|
337
|
+
/** Every legacy per-repo record: `<ring home>/<owner>/<repo>/sparring-<slug>/sparring.json`. */
|
|
338
|
+
function legacyRecords() {
|
|
339
|
+
const home = ringHome();
|
|
340
|
+
const dirs = (dir) =>
|
|
341
|
+
existsSync(dir)
|
|
342
|
+
? readdirSync(dir, { withFileTypes: true })
|
|
343
|
+
.filter((entry) => entry.isDirectory())
|
|
344
|
+
.map((entry) => entry.name)
|
|
345
|
+
: [];
|
|
346
|
+
return dirs(home)
|
|
347
|
+
.filter((owner) => owner !== 'sparrings')
|
|
348
|
+
.flatMap((owner) =>
|
|
349
|
+
dirs(join(home, owner)).flatMap((repo) =>
|
|
350
|
+
dirs(join(home, owner, repo))
|
|
351
|
+
.filter((name) => name.startsWith('sparring-'))
|
|
352
|
+
.map((name) => ({
|
|
353
|
+
repo: `${owner}/${repo}`,
|
|
354
|
+
slug: name.slice('sparring-'.length),
|
|
355
|
+
file: join(home, owner, repo, name, STATE_FILE),
|
|
356
|
+
})),
|
|
357
|
+
),
|
|
358
|
+
)
|
|
359
|
+
.filter(({ file }) => existsSync(file));
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const legacyFiles = (slug) =>
|
|
363
|
+
legacyRecords().filter((record) => record.slug === slug);
|
|
364
|
+
|
|
365
|
+
/** Files of a legacy directory the merge regenerates or already handled: never moved. */
|
|
366
|
+
const REGENERATED = [STATE_FILE, 'sparring.migrated.json', 'card.md'];
|
|
367
|
+
|
|
368
|
+
/**
|
|
369
|
+
* Moves `src` to `dst` and leaves a symlink at `src`, so a rudo or a session that kept the old
|
|
370
|
+
* path still finds it. Two directories merge child by child; anything already at `dst` stays
|
|
371
|
+
* where it was and is returned as a conflict, never overwritten.
|
|
372
|
+
*/
|
|
373
|
+
function moveLeavingLink(src, dst) {
|
|
374
|
+
if (lstatSync(src).isSymbolicLink()) return [];
|
|
375
|
+
if (!existsSync(dst)) {
|
|
376
|
+
renameSync(src, dst);
|
|
377
|
+
symlinkSync(dst, src);
|
|
378
|
+
return [];
|
|
379
|
+
}
|
|
380
|
+
if (!statSync(src).isDirectory() || !statSync(dst).isDirectory())
|
|
381
|
+
return [src];
|
|
382
|
+
return readdirSync(src).flatMap((name) =>
|
|
383
|
+
moveLeavingLink(join(src, name), join(dst, name)),
|
|
384
|
+
);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* The sparring `slug`. Legacy per-repo records are merged into one the first time: each record is
|
|
389
|
+
* renamed to `sparring.migrated.json` so it is never merged twice, and everything else in its
|
|
390
|
+
* directory (`tools/`, notes, PR bodies) moves to the sparring's directory, with a symlink left
|
|
391
|
+
* behind at the old path.
|
|
392
|
+
*/
|
|
393
|
+
function readSparring(slug) {
|
|
394
|
+
if (existsSync(fileOf(slug)))
|
|
395
|
+
return JSON.parse(readFileSync(fileOf(slug), 'utf8'));
|
|
396
|
+
const legacy = legacyFiles(checkSlug(slug));
|
|
397
|
+
if (!legacy.length)
|
|
398
|
+
throw new Error(`no sparring "${slug}" (${fileOf(slug)})`);
|
|
399
|
+
const state = mergeLegacy(
|
|
400
|
+
legacy.map(({ repo, file }) => ({
|
|
401
|
+
repo,
|
|
402
|
+
state: JSON.parse(readFileSync(file, 'utf8')),
|
|
403
|
+
})),
|
|
404
|
+
);
|
|
405
|
+
writeSparring(state);
|
|
406
|
+
const conflicts = [];
|
|
407
|
+
for (const { file } of legacy) {
|
|
408
|
+
renameSync(file, file.replace(/sparring\.json$/, 'sparring.migrated.json'));
|
|
409
|
+
const old = dirname(file);
|
|
410
|
+
for (const name of readdirSync(old).filter((n) => !REGENERATED.includes(n)))
|
|
411
|
+
conflicts.push(
|
|
412
|
+
...moveLeavingLink(join(old, name), join(dirOf(slug), name)),
|
|
413
|
+
);
|
|
414
|
+
}
|
|
415
|
+
console.error(
|
|
416
|
+
`sparring: merged ${legacy.length} per-repo record(s) of "${slug}" into ${fileOf(slug)}`,
|
|
417
|
+
);
|
|
418
|
+
if (conflicts.length)
|
|
419
|
+
console.error(
|
|
420
|
+
`sparring: left in place because the merged directory already has a file with that name: ${conflicts.join(', ')}`,
|
|
421
|
+
);
|
|
422
|
+
return state;
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function writeSparring(state) {
|
|
426
|
+
mkdirSync(dirOf(state.slug), { recursive: true });
|
|
427
|
+
writeFileSync(fileOf(state.slug), JSON.stringify(state, null, 2) + '\n');
|
|
428
|
+
writeCard(state);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
const template = (name) => readFileSync(join(TEMPLATES, name), 'utf8');
|
|
432
|
+
|
|
433
|
+
const repoLine = (entry) =>
|
|
434
|
+
[
|
|
435
|
+
`| \`${entry.repo}\``,
|
|
436
|
+
`\`${entry.branch}\` on \`${entry.base}\``,
|
|
437
|
+
entry.pr ? `#${entry.pr} ${entry.url}` : '_no PR yet_',
|
|
438
|
+
entry.preview?.url ?? '_no preview_',
|
|
439
|
+
`\`${entry.worktree}\` |`,
|
|
440
|
+
].join(' | ');
|
|
441
|
+
|
|
442
|
+
const shortcutsOf = (state) =>
|
|
443
|
+
state.repos
|
|
444
|
+
.filter((entry) => entry.shortcuts)
|
|
445
|
+
.map((entry) => `**${entry.repo}:** ${entry.shortcuts}`)
|
|
446
|
+
.join('\n\n') || '_Not stated yet._';
|
|
447
|
+
|
|
448
|
+
/** Everything the referee writes below this line survives every rewrite of the card. */
|
|
449
|
+
export const NOTES_MARKER =
|
|
450
|
+
'<!-- notes: everything below this line is kept when the card is rewritten -->';
|
|
451
|
+
|
|
452
|
+
/** The referee's notes of a card: what follows the marker, or nothing. PURE. */
|
|
453
|
+
export function keptNotes(card) {
|
|
454
|
+
const at = (card ?? '').indexOf(NOTES_MARKER);
|
|
455
|
+
return at === -1
|
|
456
|
+
? ''
|
|
457
|
+
: card.slice(at + NOTES_MARKER.length).replace(/^\n+/, '');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const verdictLine = (verdict) =>
|
|
461
|
+
`**${verdict.call}** · ${verdict.at} · ${verdict.note}`;
|
|
462
|
+
|
|
463
|
+
/** Earlier rounds, oldest first: every verdict but the last. PURE. */
|
|
464
|
+
export function earlierRounds(history) {
|
|
465
|
+
const earlier = (history ?? []).slice(0, -1);
|
|
466
|
+
if (!earlier.length) return '_None._';
|
|
467
|
+
return earlier
|
|
468
|
+
.map((verdict, i) => `${i + 1}. ${verdictLine(verdict)}`)
|
|
469
|
+
.join('\n');
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
function writeCard(state) {
|
|
473
|
+
const card = join(dirOf(state.slug), 'card.md');
|
|
474
|
+
const notes = existsSync(card) ? keptNotes(readFileSync(card, 'utf8')) : '';
|
|
475
|
+
const rendered = render(template('sparring-card.md'), {
|
|
476
|
+
slug: state.slug,
|
|
477
|
+
openedAt: state.openedAt,
|
|
478
|
+
repos: [
|
|
479
|
+
'| Repo | Branch | PR | Preview | Worktree |',
|
|
480
|
+
'| ---- | ------ | -- | ------- | -------- |',
|
|
481
|
+
...state.repos.map(repoLine),
|
|
482
|
+
].join('\n'),
|
|
483
|
+
hypothesis: state.hypothesis,
|
|
484
|
+
proof: state.proof,
|
|
485
|
+
credentials: credentialWarning(state.credentials),
|
|
486
|
+
shortcuts: shortcutsOf(state),
|
|
487
|
+
verdict: state.verdict
|
|
488
|
+
? verdictLine(state.verdict)
|
|
489
|
+
: '_Waiting on the judge._',
|
|
490
|
+
earlierRounds: earlierRounds(state.history),
|
|
491
|
+
});
|
|
492
|
+
writeFileSync(
|
|
493
|
+
card,
|
|
494
|
+
`${rendered.trimEnd()}\n\n${NOTES_MARKER}\n\n${notes}`.trimEnd() + '\n',
|
|
495
|
+
);
|
|
496
|
+
return card;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
/** The branch a worktree has checked out (unborn included), or null when HEAD is detached. */
|
|
500
|
+
function checkedOutBranch(worktree) {
|
|
501
|
+
try {
|
|
502
|
+
return exec('git', ['symbolic-ref', '--short', 'HEAD'], worktree);
|
|
503
|
+
} catch {
|
|
504
|
+
return null;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* The repo a worktree belongs to, refusing a relative path and a worktree that is not on
|
|
510
|
+
* `sparring/<slug>`: otherwise the rudo commits on one branch while `pr` opens another.
|
|
511
|
+
*/
|
|
512
|
+
function repoAt(worktree, slug) {
|
|
513
|
+
if (!isAbsolute(worktree ?? ''))
|
|
514
|
+
throw new Error('--worktree must be an absolute path');
|
|
515
|
+
const expected = sparringBranch(slug);
|
|
516
|
+
const branch = checkedOutBranch(worktree);
|
|
517
|
+
if (branch !== expected)
|
|
518
|
+
throw new Error(
|
|
519
|
+
`${worktree} is on ${branch ?? 'a detached HEAD'}, not ${expected}: create the sparring worktree on that branch first`,
|
|
520
|
+
);
|
|
521
|
+
return repoSlug(worktree);
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
function open(argv) {
|
|
525
|
+
const { flags, positional } = options(argv);
|
|
526
|
+
const [slug] = positional;
|
|
527
|
+
checkSlug(slug);
|
|
528
|
+
const missing = ['worktree', 'base', 'hypothesis', 'proof'].filter(
|
|
529
|
+
(key) => !flags[key],
|
|
530
|
+
);
|
|
531
|
+
if (missing.length) throw new Error(`open needs --${missing.join(', --')}`);
|
|
532
|
+
const credentials = parseCredentials(flags.credentials);
|
|
533
|
+
const repo = repoAt(flags.worktree, slug);
|
|
534
|
+
if (existsSync(fileOf(slug)) || legacyFiles(slug).length)
|
|
535
|
+
throw new Error(
|
|
536
|
+
`sparring "${slug}" already exists: add this repo with add-repo, or pick another slug`,
|
|
537
|
+
);
|
|
538
|
+
const state = {
|
|
539
|
+
slug,
|
|
540
|
+
hypothesis: flags.hypothesis,
|
|
541
|
+
proof: flags.proof,
|
|
542
|
+
credentials,
|
|
543
|
+
openedAt: new Date().toISOString(),
|
|
544
|
+
verdict: null,
|
|
545
|
+
repos: [
|
|
546
|
+
repoEntry({ repo, worktree: flags.worktree, base: flags.base, slug }),
|
|
547
|
+
],
|
|
548
|
+
};
|
|
549
|
+
writeSparring(state);
|
|
550
|
+
refereeDid(slug, 'abrió el sparring', {
|
|
551
|
+
detail: state.hypothesis,
|
|
552
|
+
fields: { repo },
|
|
553
|
+
});
|
|
554
|
+
console.log(
|
|
555
|
+
`sparring ${slug} opened · ${repo} on ${sparringBranch(slug)}\ncard: ${join(dirOf(slug), 'card.md')}`,
|
|
556
|
+
);
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
function addRepo(argv) {
|
|
560
|
+
const { flags, positional } = options(argv);
|
|
561
|
+
const [slug] = positional;
|
|
562
|
+
if (!flags.worktree || !flags.base)
|
|
563
|
+
throw new Error('add-repo needs --worktree and --base');
|
|
564
|
+
const repo = repoAt(flags.worktree, slug);
|
|
565
|
+
const state = readSparring(slug);
|
|
566
|
+
if (state.repos.some((entry) => entry.repo === repo))
|
|
567
|
+
throw new Error(`${repo} is already in sparring "${slug}"`);
|
|
568
|
+
writeSparring({
|
|
569
|
+
...state,
|
|
570
|
+
repos: [
|
|
571
|
+
...state.repos,
|
|
572
|
+
repoEntry({ repo, worktree: flags.worktree, base: flags.base, slug }),
|
|
573
|
+
],
|
|
574
|
+
});
|
|
575
|
+
refereeDid(slug, 'sumó un repo', { detail: repo });
|
|
576
|
+
console.log(
|
|
577
|
+
`sparring ${slug} now spans ${[...state.repos.map((e) => e.repo), repo].join(', ')}`,
|
|
578
|
+
);
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
/** Creates the label in the repo of `cwd` (the sparring's worktree) unless it is already there. */
|
|
582
|
+
function ensureLabel(cwd, name, color, description, dryRun) {
|
|
583
|
+
if (labelExists((bin, args) => exec(bin, args, cwd), name)) return;
|
|
584
|
+
const args = [
|
|
585
|
+
'label',
|
|
586
|
+
'create',
|
|
587
|
+
name,
|
|
588
|
+
'--color',
|
|
589
|
+
color,
|
|
590
|
+
'--description',
|
|
591
|
+
description,
|
|
592
|
+
];
|
|
593
|
+
if (dryRun) return console.log(`[dry-run] gh ${args.join(' ')}`);
|
|
594
|
+
exec('gh', args, cwd);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const withRepo = (state, repo, change) => ({
|
|
598
|
+
...state,
|
|
599
|
+
repos: state.repos.map((entry) =>
|
|
600
|
+
entry.repo === repo ? { ...entry, ...change } : entry,
|
|
601
|
+
),
|
|
602
|
+
});
|
|
603
|
+
|
|
604
|
+
function pr(argv) {
|
|
605
|
+
const { flags, positional } = options(argv);
|
|
606
|
+
const [slug] = positional;
|
|
607
|
+
if (!flags.title) throw new Error('pr needs --title');
|
|
608
|
+
const state = readSparring(slug);
|
|
609
|
+
const entry = pickRepo(state, flags.repo);
|
|
610
|
+
if (entry.pr)
|
|
611
|
+
throw new Error(
|
|
612
|
+
`${entry.repo} already has #${entry.pr} in sparring "${slug}"`,
|
|
613
|
+
);
|
|
614
|
+
const { config } = loadConfig(entry.worktree, { contract: false });
|
|
615
|
+
const shortcuts = flags.shortcuts ?? entry.shortcuts ?? '';
|
|
616
|
+
const siblings = state.repos
|
|
617
|
+
.filter((item) => item.repo !== entry.repo)
|
|
618
|
+
.map((item) => siblingLine(item.repo, item.url))
|
|
619
|
+
.join('\n');
|
|
620
|
+
const bodyFile = bodyFileOf(slug, entry.repo);
|
|
621
|
+
writeFileSync(
|
|
622
|
+
bodyFile,
|
|
623
|
+
render(template('pr-body.md'), {
|
|
624
|
+
slug,
|
|
625
|
+
hypothesis: state.hypothesis,
|
|
626
|
+
proof: state.proof,
|
|
627
|
+
siblings: siblings || '_This sparring has no other repo._',
|
|
628
|
+
howToTest:
|
|
629
|
+
flags['how-to-test'] ??
|
|
630
|
+
'_The preview link, once the host publishes it._',
|
|
631
|
+
shortcuts: shortcuts || '_None stated._',
|
|
632
|
+
credentials: credentialWarning(state.credentials),
|
|
633
|
+
credentialsBlock: credentialsBlock(state.credentials),
|
|
634
|
+
}),
|
|
635
|
+
);
|
|
636
|
+
const args = createArgs({
|
|
637
|
+
title: flags.title,
|
|
638
|
+
base: entry.base,
|
|
639
|
+
head: entry.branch,
|
|
640
|
+
bodyFile,
|
|
641
|
+
label: config.sparring.label,
|
|
642
|
+
});
|
|
643
|
+
ensureLabel(
|
|
644
|
+
entry.worktree,
|
|
645
|
+
config.sparring.label,
|
|
646
|
+
'F26464',
|
|
647
|
+
'Sparring draft: proves value, never merged',
|
|
648
|
+
flags['dry-run'],
|
|
649
|
+
);
|
|
650
|
+
ensureLabel(
|
|
651
|
+
entry.worktree,
|
|
652
|
+
DO_NOT_MERGE,
|
|
653
|
+
'B60205',
|
|
654
|
+
'Temporary review PR: never merge',
|
|
655
|
+
flags['dry-run'],
|
|
656
|
+
);
|
|
657
|
+
if (flags['dry-run'])
|
|
658
|
+
return console.log(
|
|
659
|
+
`[dry-run] gh ${args.map((a) => JSON.stringify(a)).join(' ')}`,
|
|
660
|
+
);
|
|
661
|
+
const url = exec('gh', args, entry.worktree);
|
|
662
|
+
const number = Number(/\/pull\/(\d+)/.exec(url)?.[1]) || null;
|
|
663
|
+
writeSparring(withRepo(state, entry.repo, { pr: number, url, shortcuts }));
|
|
664
|
+
refereeDid(slug, 'abrió el PR', {
|
|
665
|
+
detail: entry.repo,
|
|
666
|
+
...(number ? { pr: number, url } : {}),
|
|
667
|
+
});
|
|
668
|
+
console.log(`sparring PR (${entry.repo}): ${url}\nbody: ${bodyFile}`);
|
|
669
|
+
linkSiblings(state, entry.repo, url);
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
const bodyFileOf = (slug, repo) =>
|
|
673
|
+
join(dirOf(slug), `pr-body-${repo.replace('/', '-')}.md`);
|
|
674
|
+
|
|
675
|
+
/**
|
|
676
|
+
* Points the PRs opened earlier at the one just opened: their bodies were written while it did
|
|
677
|
+
* not exist yet. Edits the live body (keeping what anyone changed on GitHub). A failure is
|
|
678
|
+
* reported, never thrown: the new PR is already open and recorded.
|
|
679
|
+
*/
|
|
680
|
+
function linkSiblings(state, repo, url) {
|
|
681
|
+
for (const sibling of state.repos.filter(
|
|
682
|
+
(item) => item.repo !== repo && item.pr,
|
|
683
|
+
)) {
|
|
684
|
+
try {
|
|
685
|
+
const body = exec(
|
|
686
|
+
'gh',
|
|
687
|
+
['pr', 'view', String(sibling.pr), '--json', 'body', '-q', '.body'],
|
|
688
|
+
sibling.worktree,
|
|
689
|
+
);
|
|
690
|
+
const linked = withSiblingUrl(body, repo, url);
|
|
691
|
+
if (linked === body) continue;
|
|
692
|
+
const file = bodyFileOf(state.slug, sibling.repo);
|
|
693
|
+
writeFileSync(file, `${linked}\n`);
|
|
694
|
+
exec(
|
|
695
|
+
'gh',
|
|
696
|
+
['pr', 'edit', String(sibling.pr), '--body-file', file],
|
|
697
|
+
sibling.worktree,
|
|
698
|
+
);
|
|
699
|
+
console.log(`${sibling.repo} #${sibling.pr} now links ${url}`);
|
|
700
|
+
} catch (error) {
|
|
701
|
+
console.error(
|
|
702
|
+
`could not link ${url} from ${sibling.repo} #${sibling.pr}: ${error.message}`,
|
|
703
|
+
);
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
/** One look at the deployments of the branch tip. Throws when gh cannot say. */
|
|
709
|
+
function lookPreview(entry, environment) {
|
|
710
|
+
const sha = exec(
|
|
711
|
+
'git',
|
|
712
|
+
['ls-remote', 'origin', `refs/heads/${entry.branch}`],
|
|
713
|
+
entry.worktree,
|
|
714
|
+
).split(/\s+/)[0];
|
|
715
|
+
if (!sha) return { state: 'no-branch', url: null, done: true, sha: null };
|
|
716
|
+
const deployments = JSON.parse(
|
|
717
|
+
exec(
|
|
718
|
+
'gh',
|
|
719
|
+
[
|
|
720
|
+
'api',
|
|
721
|
+
`repos/{owner}/{repo}/deployments?sha=${sha}&environment=${encodeURIComponent(environment)}&per_page=1`,
|
|
722
|
+
],
|
|
723
|
+
entry.worktree,
|
|
724
|
+
) || '[]',
|
|
725
|
+
);
|
|
726
|
+
if (!deployments.length)
|
|
727
|
+
return { state: 'pending', url: null, done: false, sha };
|
|
728
|
+
const statuses = JSON.parse(
|
|
729
|
+
exec(
|
|
730
|
+
'gh',
|
|
731
|
+
[
|
|
732
|
+
'api',
|
|
733
|
+
`repos/{owner}/{repo}/deployments/${deployments[0].id}/statuses?per_page=1`,
|
|
734
|
+
],
|
|
735
|
+
entry.worktree,
|
|
736
|
+
) || '[]',
|
|
737
|
+
);
|
|
738
|
+
return { ...previewOf(statuses), sha };
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
const sleep = (seconds) =>
|
|
742
|
+
new Promise((done) => setTimeout(done, seconds * 1000));
|
|
743
|
+
|
|
744
|
+
/** Waits for one repo's preview, bounded by its previewTimeoutMin; null when it configured none. */
|
|
745
|
+
async function previewOfRepo(entry, wait) {
|
|
746
|
+
const { config } = loadConfig(entry.worktree, { contract: false });
|
|
747
|
+
const environment = config.sparring.previewEnvironment;
|
|
748
|
+
if (!environment) return null;
|
|
749
|
+
// Bounded on purpose: a wait never outlives previewTimeoutMin, and stops on a terminal state.
|
|
750
|
+
const deadline = Date.now() + config.sparring.previewTimeoutMin * 60_000;
|
|
751
|
+
let seen = lookPreview(entry, environment);
|
|
752
|
+
while (wait && !seen.done && Date.now() < deadline) {
|
|
753
|
+
await sleep(nextPollSeconds(deadline, Date.now()));
|
|
754
|
+
if (Date.now() >= deadline) break;
|
|
755
|
+
seen = lookPreview(entry, environment);
|
|
756
|
+
}
|
|
757
|
+
return {
|
|
758
|
+
...seen,
|
|
759
|
+
environment,
|
|
760
|
+
timedOut: wait && !seen.done,
|
|
761
|
+
timeoutMin: config.sparring.previewTimeoutMin,
|
|
762
|
+
checkedAt: new Date().toISOString(),
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
async function preview(argv) {
|
|
767
|
+
const { flags, positional } = options(argv);
|
|
768
|
+
const [slug] = positional;
|
|
769
|
+
let state = readSparring(slug);
|
|
770
|
+
const entries = flags.repo ? [pickRepo(state, flags.repo)] : state.repos;
|
|
771
|
+
for (const entry of entries) {
|
|
772
|
+
const seen = await previewOfRepo(entry, flags.wait);
|
|
773
|
+
if (!seen) {
|
|
774
|
+
console.log(
|
|
775
|
+
`${entry.repo}: no preview configured (its .claude/ring.json has no sparring.previewEnvironment); the judge tests it locally`,
|
|
776
|
+
);
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
state = withRepo(state, entry.repo, { preview: seen });
|
|
780
|
+
refereeDid(slug, seen.url ? 'preview lista' : 'sin preview', {
|
|
781
|
+
detail: entry.repo,
|
|
782
|
+
...(seen.url
|
|
783
|
+
? { url: seen.url, health: 'ok' }
|
|
784
|
+
: { health: 'warn', reason: seen.state }),
|
|
785
|
+
});
|
|
786
|
+
const at = seen.sha ? ` @ ${seen.sha.slice(0, 9)}` : '';
|
|
787
|
+
console.log(
|
|
788
|
+
seen.url
|
|
789
|
+
? `${entry.repo}: preview ready (${seen.environment}${at}): ${seen.url}`
|
|
790
|
+
: `${entry.repo}: no preview link: ${seen.state}${seen.timedOut ? ` after ${seen.timeoutMin} min` : ''} (${seen.environment}${at})`,
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
writeSparring(state);
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function verdict(argv) {
|
|
797
|
+
const { flags, positional } = options(argv);
|
|
798
|
+
const [slug, call] = positional;
|
|
799
|
+
if (!VERDICTS.includes(call))
|
|
800
|
+
throw new Error(`the verdict is one of ${VERDICTS.join(', ')}`);
|
|
801
|
+
if (!flags.note)
|
|
802
|
+
throw new Error('verdict needs --note (why, with the evidence)');
|
|
803
|
+
const state = readSparring(slug);
|
|
804
|
+
if (FINAL_VERDICTS.includes(state.verdict?.call))
|
|
805
|
+
throw new Error(
|
|
806
|
+
`sparring "${slug}" already has a final verdict (${state.verdict.call}): it cannot be called again`,
|
|
807
|
+
);
|
|
808
|
+
const verdict = { call, note: flags.note, at: new Date().toISOString() };
|
|
809
|
+
writeSparring({
|
|
810
|
+
...state,
|
|
811
|
+
verdict,
|
|
812
|
+
history: [
|
|
813
|
+
...(state.history ?? (state.verdict ? [state.verdict] : [])),
|
|
814
|
+
verdict,
|
|
815
|
+
],
|
|
816
|
+
});
|
|
817
|
+
feed(slug, {
|
|
818
|
+
actor: 'juez',
|
|
819
|
+
state: 'done',
|
|
820
|
+
label: `veredicto ${call}`,
|
|
821
|
+
detail: flags.note,
|
|
822
|
+
});
|
|
823
|
+
console.log(
|
|
824
|
+
`sparring ${slug}: ${call}\ncard: ${join(dirOf(slug), 'card.md')}`,
|
|
825
|
+
);
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/**
|
|
829
|
+
* The gates the vestidor runs, in this order: the ones that say whether the branch could be
|
|
830
|
+
* deployed. `test` is left out on purpose: the host's test gate takes `{paths}` (the whole suite is
|
|
831
|
+
* not one of its gates) and the referee bout runs the tests of what it touches.
|
|
832
|
+
*/
|
|
833
|
+
export const VESTIDOR_GATES = ['typecheck', 'lint', 'build'];
|
|
834
|
+
|
|
835
|
+
/**
|
|
836
|
+
* What the vestidor runs, per repo of the sparring, in order. PURE. Refuses unless the last verdict
|
|
837
|
+
* is a KO. `gatesOf(entry)` gives that repo's resolved gates. Each step is { repo, worktree, runs,
|
|
838
|
+
* skipped, problems }: a problem (no gate at all, a gate that takes `{paths}`) fails the vestidor
|
|
839
|
+
* without running anything for that gate.
|
|
840
|
+
*/
|
|
841
|
+
export function vestidorPlan(state, gatesOf) {
|
|
842
|
+
const call = state.verdict?.call ?? 'no verdict';
|
|
843
|
+
if (call !== 'ko')
|
|
844
|
+
throw new Error(
|
|
845
|
+
`the vestidor comes after a KO; sparring "${state.slug}" is at ${call}`,
|
|
846
|
+
);
|
|
847
|
+
return state.repos.map((entry) => {
|
|
848
|
+
const gates = gatesOf(entry) ?? {};
|
|
849
|
+
const configured = VESTIDOR_GATES.filter((gate) => gates[gate]).map(
|
|
850
|
+
(gate) => ({ gate, command: gates[gate] }),
|
|
851
|
+
);
|
|
852
|
+
const byPaths = configured.filter((run) => run.command.includes('{paths}'));
|
|
853
|
+
return {
|
|
854
|
+
repo: entry.repo,
|
|
855
|
+
worktree: entry.worktree,
|
|
856
|
+
runs: configured.filter((run) => !byPaths.includes(run)),
|
|
857
|
+
skipped: VESTIDOR_GATES.filter((gate) => !gates[gate]),
|
|
858
|
+
problems: [
|
|
859
|
+
...(configured.length
|
|
860
|
+
? []
|
|
861
|
+
: [
|
|
862
|
+
'no typecheck, lint or build gate (configure it with /ring:weigh-in)',
|
|
863
|
+
]),
|
|
864
|
+
...byPaths.map(
|
|
865
|
+
(run) =>
|
|
866
|
+
`${run.gate} takes {paths}; the vestidor needs a whole-repo command`,
|
|
867
|
+
),
|
|
868
|
+
],
|
|
869
|
+
};
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
/**
|
|
874
|
+
* The vestidor's result from its plan and the exit code of each run ({ repo, gate, code }). PURE.
|
|
875
|
+
* Passes only when every repo ran something, nothing had a problem and every code is 0. `note` is
|
|
876
|
+
* the one line the milestone keeps: `owner/repo: typecheck 0 · lint 0 · build 0`.
|
|
877
|
+
*/
|
|
878
|
+
export function vestidorOutcome(plan, results) {
|
|
879
|
+
const codeOf = (repo, gate) =>
|
|
880
|
+
results.find((r) => r.repo === repo && r.gate === gate)?.code ?? null;
|
|
881
|
+
const failed = plan.flatMap((step) => [
|
|
882
|
+
...step.problems.map((problem) => `${step.repo}: ${problem}`),
|
|
883
|
+
...step.runs
|
|
884
|
+
.filter((run) => codeOf(step.repo, run.gate) !== 0)
|
|
885
|
+
.map(
|
|
886
|
+
(run) =>
|
|
887
|
+
`${step.repo}: ${run.gate} exited ${codeOf(step.repo, run.gate) ?? 'without running'} (${run.command})`,
|
|
888
|
+
),
|
|
889
|
+
]);
|
|
890
|
+
const note = plan
|
|
891
|
+
.map(
|
|
892
|
+
(step) =>
|
|
893
|
+
`${step.repo}: ${step.runs.map((run) => `${run.gate} ${codeOf(step.repo, run.gate)}`).join(' · ') || 'nothing ran'}`,
|
|
894
|
+
)
|
|
895
|
+
.join('; ');
|
|
896
|
+
return { passed: failed.length === 0, failed, note };
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
const MILESTONE_LABELS = {
|
|
900
|
+
vestidor: 'pasó al vestidor',
|
|
901
|
+
defense: 'abrió la defensa',
|
|
902
|
+
bell: 'campana de la defensa',
|
|
903
|
+
'mask-kept': 'conserva la máscara',
|
|
904
|
+
'mask-lost': 'pierde la máscara',
|
|
905
|
+
'no-team': 'sin equipo: directo al árbitro',
|
|
906
|
+
referee: 'pasó al árbitro',
|
|
907
|
+
};
|
|
908
|
+
|
|
909
|
+
/** Stamps a milestone on the sparring, tells the feed and says to refresh the chronicle. */
|
|
910
|
+
function stamp(slug, state, kind, note) {
|
|
911
|
+
const next = withMilestone(state, kind, note);
|
|
912
|
+
writeSparring(next);
|
|
913
|
+
refereeDid(slug, MILESTONE_LABELS[kind], { detail: note });
|
|
914
|
+
console.log(
|
|
915
|
+
`sparring ${slug}: ${kind} at ${next.milestones.at(-1).at}; refresh the chronicle (/ring:play-by-play --sparring ${slug})`,
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
function mark(argv) {
|
|
920
|
+
const { flags, positional } = options(argv);
|
|
921
|
+
const [slug, kind] = positional;
|
|
922
|
+
checkMilestone(kind);
|
|
923
|
+
stamp(slug, readSparring(slug), kind, flags.note);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** The last lines of a gate's output, for the referee to see why it failed. PURE. */
|
|
927
|
+
const tail = (text, lines = 20) =>
|
|
928
|
+
String(text ?? '')
|
|
929
|
+
.trimEnd()
|
|
930
|
+
.split('\n')
|
|
931
|
+
.slice(-lines)
|
|
932
|
+
.join('\n');
|
|
933
|
+
|
|
934
|
+
/**
|
|
935
|
+
* Runs one gate in its repo's worktree through the machine lock (one-at-a-time, as every heavy
|
|
936
|
+
* command of the ring) and keeps its whole output in `<sparring dir>/vestidor/`.
|
|
937
|
+
*/
|
|
938
|
+
function runGate(slug, step, run) {
|
|
939
|
+
const out = spawnSync(
|
|
940
|
+
process.execPath,
|
|
941
|
+
[join(HERE, 'one-at-a-time.mjs'), 'run', '--', run.command],
|
|
942
|
+
{
|
|
943
|
+
cwd: step.worktree,
|
|
944
|
+
encoding: 'utf8',
|
|
945
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
946
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
947
|
+
},
|
|
948
|
+
);
|
|
949
|
+
const log = join(
|
|
950
|
+
dirOf(slug),
|
|
951
|
+
'vestidor',
|
|
952
|
+
`${step.repo.replace('/', '-')}-${run.gate}.log`,
|
|
953
|
+
);
|
|
954
|
+
mkdirSync(dirname(log), { recursive: true });
|
|
955
|
+
const output = `${out.stdout ?? ''}${out.stderr ?? ''}${out.error ? `\n${out.error.message}` : ''}`;
|
|
956
|
+
writeFileSync(log, `$ ${run.command}\n${output}`);
|
|
957
|
+
const code = out.status ?? 1;
|
|
958
|
+
console.log(`${step.repo} · ${run.gate}: exit ${code} · ${run.command}`);
|
|
959
|
+
if (code !== 0)
|
|
960
|
+
console.log(` log: ${log}\n${tail(output).replace(/^/gm, ' | ')}`);
|
|
961
|
+
return { repo: step.repo, gate: run.gate, code };
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
function vestidor(argv) {
|
|
965
|
+
const { positional } = options(argv);
|
|
966
|
+
const [slug] = positional;
|
|
967
|
+
const state = readSparring(slug);
|
|
968
|
+
const plan = vestidorPlan(state, (entry) =>
|
|
969
|
+
existsSync(entry.worktree)
|
|
970
|
+
? loadConfig(entry.worktree, { contract: false }).config.gates
|
|
971
|
+
: null,
|
|
972
|
+
);
|
|
973
|
+
const results = [];
|
|
974
|
+
for (const step of plan) {
|
|
975
|
+
if (!existsSync(step.worktree)) {
|
|
976
|
+
step.problems = [`its worktree ${step.worktree} is gone`];
|
|
977
|
+
continue;
|
|
978
|
+
}
|
|
979
|
+
console.log(
|
|
980
|
+
`${step.repo}: ${step.runs.map((run) => run.gate).join(', ') || 'nothing to run'}${step.skipped.length ? ` (not configured: ${step.skipped.join(', ')})` : ''}`,
|
|
981
|
+
);
|
|
982
|
+
for (const run of step.runs) results.push(runGate(slug, step, run));
|
|
983
|
+
}
|
|
984
|
+
const outcome = vestidorOutcome(plan, results);
|
|
985
|
+
if (!outcome.passed) {
|
|
986
|
+
feed(slug, {
|
|
987
|
+
actor: 'arbitro',
|
|
988
|
+
state: 'done',
|
|
989
|
+
label: 'no pasó el vestidor',
|
|
990
|
+
detail: outcome.note,
|
|
991
|
+
health: 'fail',
|
|
992
|
+
reason: outcome.failed[0],
|
|
993
|
+
});
|
|
994
|
+
throw new Error(
|
|
995
|
+
`the vestidor did not pass; nothing was stamped:\n- ${outcome.failed.join('\n- ')}`,
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
stamp(slug, state, 'vestidor', outcome.note);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
function playByPlayUrl(argv) {
|
|
1002
|
+
const { positional } = options(argv);
|
|
1003
|
+
const [slug, url] = positional;
|
|
1004
|
+
if (!slug || !url) throw new Error('usage: play-by-play-url <slug> <url>');
|
|
1005
|
+
const next = withChronicleUrl(
|
|
1006
|
+
readSparring(slug),
|
|
1007
|
+
url,
|
|
1008
|
+
undefined,
|
|
1009
|
+
'this fight',
|
|
1010
|
+
);
|
|
1011
|
+
writeSparring(next);
|
|
1012
|
+
refereeDid(slug, 'publicó la crónica', { url });
|
|
1013
|
+
console.log(
|
|
1014
|
+
`play-by-play link for sparring ${slug}: ${url} (published ${next.playByPlay.publishedAt})`,
|
|
1015
|
+
);
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
/**
|
|
1019
|
+
* After a KO: per repo, `release/<slug>` from its base (reused if it exists), `mainevent/<slug>`
|
|
1020
|
+
* from the sparring tip, and the deliverable PR mainevent → release as a draft. The bout then
|
|
1021
|
+
* starts on that PR (`/ring:referee start --from-sparring <slug>`).
|
|
1022
|
+
*/
|
|
1023
|
+
function promote(argv) {
|
|
1024
|
+
const { flags, positional } = options(argv);
|
|
1025
|
+
const [slug] = positional;
|
|
1026
|
+
let state = readSparring(slug);
|
|
1027
|
+
const entries = flags.repo ? [pickRepo(state, flags.repo)] : state.repos;
|
|
1028
|
+
for (const entry of entries) {
|
|
1029
|
+
if (!flags['dry-run']) exec('git', ['fetch', 'origin'], entry.worktree);
|
|
1030
|
+
const heads = exec(
|
|
1031
|
+
'git',
|
|
1032
|
+
['ls-remote', '--heads', 'origin'],
|
|
1033
|
+
entry.worktree,
|
|
1034
|
+
)
|
|
1035
|
+
.split('\n')
|
|
1036
|
+
.map((line) => line.split(/\s+/)[1]?.replace('refs/heads/', ''))
|
|
1037
|
+
.filter(Boolean);
|
|
1038
|
+
const plan = promotionPlan(state, entry, heads);
|
|
1039
|
+
const bodyFile = join(
|
|
1040
|
+
dirOf(slug),
|
|
1041
|
+
`mainevent-body-${entry.repo.replace('/', '-')}.md`,
|
|
1042
|
+
);
|
|
1043
|
+
writeFileSync(
|
|
1044
|
+
bodyFile,
|
|
1045
|
+
[
|
|
1046
|
+
`Entregable del sparring \`${slug}\` (KO: ${state.verdict.note}).`,
|
|
1047
|
+
'',
|
|
1048
|
+
`- Borrador que validó el KO: ${entry.url ?? `\`${entry.branch}\``} (do-not-merge, no se mezcla).`,
|
|
1049
|
+
`- Esta rama nace de su punta; el combate del ring la limpia aquí. Llega a \`main\` por \`${plan.release}\`.`,
|
|
1050
|
+
'- Antes de limpiar, el contrato (spec y modelos) se le muestra al juez y queda congelado hasta que lo apruebe.',
|
|
1051
|
+
'',
|
|
1052
|
+
`Hipótesis: ${state.hypothesis}`,
|
|
1053
|
+
].join('\n'),
|
|
1054
|
+
);
|
|
1055
|
+
const args = mainEventArgs({
|
|
1056
|
+
title: flags.title ?? `${slug}: entregable del sparring`,
|
|
1057
|
+
release: plan.release,
|
|
1058
|
+
mainevent: plan.mainevent,
|
|
1059
|
+
bodyFile,
|
|
1060
|
+
});
|
|
1061
|
+
if (flags['dry-run']) {
|
|
1062
|
+
for (const refspec of plan.pushes)
|
|
1063
|
+
console.log(`[dry-run] git push origin ${refspec}`);
|
|
1064
|
+
console.log(
|
|
1065
|
+
`[dry-run] gh ${args.map((a) => JSON.stringify(a)).join(' ')}`,
|
|
1066
|
+
);
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
for (const refspec of plan.pushes)
|
|
1070
|
+
exec('git', ['push', 'origin', refspec], entry.worktree);
|
|
1071
|
+
const url = exec('gh', args, entry.worktree);
|
|
1072
|
+
const number = Number(/\/pull\/(\d+)/.exec(url)?.[1]) || null;
|
|
1073
|
+
state = withRepo(state, entry.repo, {
|
|
1074
|
+
mainevent: {
|
|
1075
|
+
branch: plan.mainevent,
|
|
1076
|
+
release: plan.release,
|
|
1077
|
+
pr: number,
|
|
1078
|
+
url,
|
|
1079
|
+
},
|
|
1080
|
+
});
|
|
1081
|
+
refereeDid(slug, 'promovió el KO', {
|
|
1082
|
+
detail: `${entry.repo}: ${plan.mainevent} → ${plan.release}`,
|
|
1083
|
+
...(number ? { pr: number, url } : {}),
|
|
1084
|
+
});
|
|
1085
|
+
console.log(`${entry.repo}: ${plan.mainevent} → ${plan.release}: ${url}`);
|
|
1086
|
+
}
|
|
1087
|
+
writeSparring(state);
|
|
1088
|
+
}
|
|
1089
|
+
|
|
1090
|
+
/** Days before a sparring without a verdict is stale: its first repo's config, else the default. */
|
|
1091
|
+
function staleDaysOf(state) {
|
|
1092
|
+
try {
|
|
1093
|
+
return loadConfig(state.repos[0].worktree, { contract: false }).config
|
|
1094
|
+
.sparring.staleDays;
|
|
1095
|
+
} catch {
|
|
1096
|
+
return DEFAULTS.sparring.staleDays;
|
|
1097
|
+
}
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
function list() {
|
|
1101
|
+
const current = existsSync(sparringsHome())
|
|
1102
|
+
? readdirSync(sparringsHome()).filter((slug) => existsSync(fileOf(slug)))
|
|
1103
|
+
: [];
|
|
1104
|
+
const legacy = legacyRecords().map((record) => record.slug);
|
|
1105
|
+
const slugs = [...new Set([...current, ...legacy])].sort();
|
|
1106
|
+
if (!slugs.length) return console.log('no sparrings');
|
|
1107
|
+
const now = new Date().toISOString();
|
|
1108
|
+
for (const slug of slugs) {
|
|
1109
|
+
const state = readSparring(slug);
|
|
1110
|
+
const repos = state.repos
|
|
1111
|
+
.map((entry) => `${entry.repo}${entry.pr ? ` #${entry.pr}` : ''}`)
|
|
1112
|
+
.join(', ');
|
|
1113
|
+
console.log(
|
|
1114
|
+
[
|
|
1115
|
+
state.slug,
|
|
1116
|
+
repos,
|
|
1117
|
+
state.verdict?.call ?? 'no verdict',
|
|
1118
|
+
`${ageDays(state.openedAt, now)} d`,
|
|
1119
|
+
isStale(state, now, staleDaysOf(state)) ? 'STALE' : '',
|
|
1120
|
+
]
|
|
1121
|
+
.filter(Boolean)
|
|
1122
|
+
.join(' · '),
|
|
1123
|
+
);
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
if (isMain(import.meta.url)) {
|
|
1128
|
+
const [command, ...rest] = process.argv.slice(2);
|
|
1129
|
+
const run = {
|
|
1130
|
+
open,
|
|
1131
|
+
'add-repo': addRepo,
|
|
1132
|
+
pr,
|
|
1133
|
+
preview,
|
|
1134
|
+
verdict,
|
|
1135
|
+
vestidor,
|
|
1136
|
+
mark,
|
|
1137
|
+
'play-by-play-url': playByPlayUrl,
|
|
1138
|
+
promote,
|
|
1139
|
+
list,
|
|
1140
|
+
}[command];
|
|
1141
|
+
if (!run) {
|
|
1142
|
+
console.error(
|
|
1143
|
+
'Usage: node sparring.mjs open <slug> … | add-repo <slug> --worktree <abs> --base <b> | pr <slug> --title <t> [--repo <owner/repo>] | preview <slug> [--repo <owner/repo>] [--wait] | verdict <slug> ko|rematch|discard --note <t> | vestidor <slug> | mark <slug> <milestone> [--note <t>] | play-by-play-url <slug> <url> | promote <slug> [--repo <owner/repo>] [--title <t>] [--dry-run] | list',
|
|
1144
|
+
);
|
|
1145
|
+
process.exit(1);
|
|
1146
|
+
}
|
|
1147
|
+
Promise.resolve()
|
|
1148
|
+
.then(() => run(rest))
|
|
1149
|
+
.then(() => {
|
|
1150
|
+
// Reading the list is not refereeing: only the commands that act register the session.
|
|
1151
|
+
const session = process.env.CLAUDE_CODE_SESSION_ID;
|
|
1152
|
+
if (command !== 'list' && session)
|
|
1153
|
+
registerSparringSession(dirOf(rest[0]), session);
|
|
1154
|
+
})
|
|
1155
|
+
.catch((error) => {
|
|
1156
|
+
console.error(
|
|
1157
|
+
`sparring: ${error.stderr?.toString().trim() || error.message}`,
|
|
1158
|
+
);
|
|
1159
|
+
process.exit(1);
|
|
1160
|
+
});
|
|
1161
|
+
}
|