@thebattlearena/corner 0.9.0 → 0.10.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 (45) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/ring/agents/cornerman.md +20 -3
  4. package/ring/agents/wrestler.md +11 -0
  5. package/ring/hooks/hooks.json +20 -0
  6. package/ring/scripts/bout-stats-render.mjs +366 -0
  7. package/ring/scripts/bout-stats.mjs +360 -0
  8. package/ring/scripts/config.mjs +1 -1
  9. package/ring/scripts/contract-endpoints.mjs +163 -0
  10. package/ring/scripts/contract-preview.mjs +34 -14
  11. package/ring/scripts/fight.mjs +27 -0
  12. package/ring/scripts/findings.mjs +3 -1
  13. package/ring/scripts/format-gate.mjs +723 -0
  14. package/ring/scripts/format-worker.mjs +120 -0
  15. package/ring/scripts/front-routes.mjs +345 -0
  16. package/ring/scripts/judges-card.mjs +324 -3
  17. package/ring/scripts/lookout.mjs +22 -4
  18. package/ring/scripts/mask-guard.mjs +34 -1
  19. package/ring/scripts/page-embed.mjs +112 -0
  20. package/ring/scripts/play-by-play.mjs +155 -7
  21. package/ring/scripts/preparation-collect.mjs +177 -0
  22. package/ring/scripts/preparation.mjs +547 -0
  23. package/ring/scripts/round-bell.mjs +9 -2
  24. package/ring/scripts/tech-plan-log.mjs +81 -0
  25. package/ring/scripts/tech-plan.mjs +191 -0
  26. package/ring/scripts/verdicts-log.mjs +75 -0
  27. package/ring/scripts/verdicts.mjs +205 -0
  28. package/ring/skills/play-by-play/SKILL.md +235 -33
  29. package/ring/skills/play-by-play/templates/blocks.css +196 -0
  30. package/ring/skills/play-by-play/templates/chronicle.html +358 -55
  31. package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
  32. package/ring/skills/play-by-play/templates/interview-form.js +516 -0
  33. package/ring/skills/play-by-play/templates/now-panel.js +719 -0
  34. package/ring/skills/referee/SKILL.md +136 -42
  35. package/ring/skills/referee/references/api-diagram.md +56 -0
  36. package/ring/skills/referee/references/precedents.md +11 -0
  37. package/ring/skills/referee/references/rulebook.md +65 -4
  38. package/ring/skills/referee/templates/api-diagram.html +115 -0
  39. package/ring/skills/referee/templates/escalation.md +7 -1
  40. package/ring/skills/referee/templates/round-bell-body.md +4 -0
  41. package/ring/skills/referee/templates/wrestler-brief.md +3 -0
  42. package/ring/skills/sparring/SKILL.md +10 -2
  43. package/ring/skills/sparring/templates/contrato.md +29 -0
  44. package/ring/skills/sparring/templates/rudo-brief.md +7 -0
  45. package/ring/skills/weigh-in/SKILL.md +6 -0
@@ -8,14 +8,22 @@
8
8
  // A fight that starts at a sparring is one chronicle (fight.mjs): `--sparring <slug>`, or a bout
9
9
  // opened `--from-sparring <slug>`, prints the sparring's falls and every bout of it as the third.
10
10
  //
11
- // Output: { pr, repo, boutDir, generatedAt, timezone, copilotOnHead, rounds, events, score, gaps,
12
- // notes }. Every event carries its real time (`at`, plus `local` HH:MM in --tz) and its `source`.
11
+ // node play-by-play.mjs embed <page.html> [<bout dir>/db-diagram.html…] [--contract <bout dir>…]
12
+ // [--interview <bout dir>…] (the questions' context for «Ver más información»)
13
+ // [--routes <bout dir>…] (a frontend bout's «Rutas del frontend»)
14
+ // [--plan <bout dir>…] («El plan del técnico», from plan-tecnico.md)
15
+ // after writing the page: each bout's diagram and endpoints go after their
16
+ // placeholders, and the page's scripts at its end (page-embed.mjs)
17
+ //
18
+ // Output: { pr, repo, boutDir, generatedAt, timezone, copilotOnHead, weighIn, interview, rounds,
19
+ // events, score, gaps, notes }. `weighIn` and `interview` are the bout's preparation, told before
20
+ // its first round (preparation.mjs); the events they tell carry `stage`. Every event carries its real time (`at`, plus `local` HH:MM in --tz) and its `source`.
13
21
  // Anything without a recorded time goes to `gaps` with the two known times around it; nothing is
14
22
  // ever given an invented time. --extra adds times the human or another tool provides (an array of
15
23
  // { at, type, title, detail, ref }, inline or in a file), always marked `provided`.
16
24
  import { execFileSync } from 'node:child_process';
17
- import { existsSync, readFileSync } from 'node:fs';
18
- import { dirname, join } from 'node:path';
25
+ import { existsSync, readFileSync, writeFileSync } from 'node:fs';
26
+ import { basename, dirname, join } from 'node:path';
19
27
 
20
28
  import {
21
29
  checkEvents,
@@ -34,11 +42,33 @@ import {
34
42
  boutDir,
35
43
  isMain,
36
44
  projectDir,
45
+ RING_ROOT,
37
46
  readState,
38
47
  repoSlug,
39
48
  slugFromRemote,
40
49
  } from './config.mjs';
41
50
  import { collectFight } from './fight.mjs';
51
+ import {
52
+ INTERVIEW,
53
+ contextWarnings,
54
+ pageContexts,
55
+ parseInterview,
56
+ withLocalTimes,
57
+ withStages,
58
+ } from './preparation.mjs';
59
+ import {
60
+ collectContractSources,
61
+ endpointsBlock,
62
+ } from './contract-endpoints.mjs';
63
+ import { collectFrontRoutes, routesBlock } from './front-routes.mjs';
64
+ import { collectPreparation } from './preparation-collect.mjs';
65
+ import { planBlock } from './tech-plan-log.mjs';
66
+ import {
67
+ embedBlocks,
68
+ embedContexts,
69
+ embedDiagrams,
70
+ embedScripts,
71
+ } from './page-embed.mjs';
42
72
 
43
73
  const run = (bin, args, cwd) =>
44
74
  execFileSync(bin, args, {
@@ -82,7 +112,109 @@ export function readExtra(value) {
82
112
  }
83
113
 
84
114
  const USAGE =
85
- 'usage: play-by-play.mjs <pr> [--repo <owner/name>] | --sparring <slug>; [--tz <zone>] [--extra <json|file>] [--no-gh]';
115
+ 'usage: play-by-play.mjs <pr> [--repo <owner/name>] | --sparring <slug>; [--tz <zone>] [--extra <json|file>] [--no-gh] | embed <page.html> [<bout dir>/db-diagram.html…] [--contract <bout dir>…] [--interview <bout dir>…] [--routes <bout dir>…] [--plan <bout dir>…]';
116
+
117
+ /**
118
+ * `embed <page> [<bout dir>/db-diagram.html…] [--contract <bout dir>…]`: puts each bout's diagram
119
+ * after its `ring:db-diagram #<pr>` placeholder (the bout's number is its directory's name), each
120
+ * bout's «Endpoints del contrato» after its `ring:api-contract #<pr>` placeholder, and the page's
121
+ * scripts at its end (page-embed.mjs). Run it after every rewrite of the page.
122
+ */
123
+ const FLAGS = ['--contract', '--interview', '--routes', '--plan'];
124
+
125
+ function embed(argv) {
126
+ const warnings = [];
127
+ const [page] = argv;
128
+ if (!page) throw new Error(USAGE);
129
+ const valuesOf = (flag) =>
130
+ argv.flatMap((arg, index) => (argv[index - 1] === flag ? [arg] : []));
131
+ const contracts = valuesOf('--contract');
132
+ const interviews = valuesOf('--interview');
133
+ const routeDirs = valuesOf('--routes');
134
+ const planDirs = valuesOf('--plan');
135
+ const diagrams = argv
136
+ .slice(1)
137
+ .filter(
138
+ (arg, index, rest) =>
139
+ !FLAGS.includes(arg) && !FLAGS.includes(rest[index - 1]),
140
+ );
141
+ const byPr = Object.fromEntries(
142
+ diagrams.map((path) => [
143
+ basename(dirname(path)),
144
+ readFileSync(path, 'utf8'),
145
+ ]),
146
+ );
147
+ const specs = Object.fromEntries(
148
+ contracts.map((dir) => {
149
+ const sources = collectContractSources(dir);
150
+ return [String(sources.pr), endpointsBlock(sources)];
151
+ }),
152
+ );
153
+ let after = embedDiagrams(readFileSync(page, 'utf8'), byPr);
154
+ after = embedBlocks(after, 'api-contract', specs);
155
+ const routeBlocks = Object.fromEntries(
156
+ routeDirs.map((dir) => {
157
+ const routes = collectFrontRoutes(dir);
158
+ return [String(routes.pr), routesBlock(routes)];
159
+ }),
160
+ );
161
+ after = embedBlocks(after, 'front-routes', routeBlocks);
162
+ const planBlocks = Object.fromEntries(
163
+ planDirs.map((dir) => {
164
+ const block = planBlock(dir);
165
+ return [String(block.pr), block.html];
166
+ }),
167
+ );
168
+ after = embedBlocks(after, 'tech-plan', planBlocks);
169
+ const contexts = Object.assign(
170
+ {},
171
+ ...interviews.map((dir) => {
172
+ const file = join(dir, INTERVIEW);
173
+ const entries = parseInterview(
174
+ existsSync(file) ? readFileSync(file, 'utf8') : '',
175
+ );
176
+ const pr = readState(dir).pr;
177
+ const empty = contextWarnings(pr, entries);
178
+ warnings.push(
179
+ ...empty.map(
180
+ (id) =>
181
+ `${id}: its context has no «## Contexto <n> · …» section for those questions, so they do not show «Ver más información»`,
182
+ ),
183
+ );
184
+ return pageContexts(pr, entries);
185
+ }),
186
+ );
187
+ after = embedContexts(after, contexts);
188
+ const missing = [
189
+ ...Object.keys(byPr).map((pr) => `ring:db-diagram #${pr}`),
190
+ ...Object.keys(specs).map((pr) => `ring:api-contract #${pr}`),
191
+ ...Object.keys(routeBlocks).map((pr) => `ring:front-routes #${pr}`),
192
+ ...Object.keys(planBlocks).map((pr) => `ring:tech-plan #${pr}`),
193
+ ].filter((marker) => !after.includes(`<!-- ${marker} -->`));
194
+ if (missing.length)
195
+ throw new Error(`${page} has no placeholder <!-- ${missing[0]} -->`);
196
+ const template = (name) =>
197
+ readFileSync(
198
+ join(RING_ROOT, 'skills', 'play-by-play', 'templates', name),
199
+ 'utf8',
200
+ );
201
+ after = embedScripts(after, {
202
+ css: template('blocks.css'),
203
+ form: template('interview-form.js'),
204
+ endpoints: template('endpoints-view.js'),
205
+ panel: template('now-panel.js'),
206
+ });
207
+ writeFileSync(page, after);
208
+ return {
209
+ page,
210
+ diagrams: Object.keys(byPr).map(Number),
211
+ contracts: Object.keys(specs).map(Number),
212
+ routes: Object.keys(routeBlocks).map(Number),
213
+ plans: Object.keys(planBlocks).map(Number),
214
+ contexts: Object.keys(contexts),
215
+ warnings,
216
+ };
217
+ }
86
218
 
87
219
  const timezoneOf = (argv) =>
88
220
  option(argv, 'tz') ?? Intl.DateTimeFormat().resolvedOptions().timeZone;
@@ -109,8 +241,9 @@ export function collectBout([pr, ...argv]) {
109
241
  const read = (name) =>
110
242
  existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
111
243
 
244
+ const decisionsText = read('decisions.md');
112
245
  const decisions = parseLedger(
113
- read('decisions.md'),
246
+ decisionsText,
114
247
  'decisions',
115
248
  'file:decisions.md',
116
249
  );
@@ -235,6 +368,18 @@ export function collectBout([pr, ...argv]) {
235
368
  gaps.push(...extra.gaps);
236
369
  }
237
370
 
371
+ const preparation = collectPreparation({
372
+ dir,
373
+ state,
374
+ opened,
375
+ decisionsText,
376
+ notes,
377
+ });
378
+ events = withStages(
379
+ [...events, ...preparation.events],
380
+ preparation.interview,
381
+ preparation.scale,
382
+ );
238
383
  const sliced = sliceRounds(events);
239
384
  events = sliced.events.map((event) => ({
240
385
  ...event,
@@ -248,6 +393,7 @@ export function collectBout([pr, ...argv]) {
248
393
  timezone,
249
394
  opened,
250
395
  copilotOnHead: onHead,
396
+ ...withLocalTimes(preparation, (at) => localTime(at, timezone)),
251
397
  rounds: sliced.rounds,
252
398
  events,
253
399
  score: score(events, sliced.rounds),
@@ -274,7 +420,9 @@ function collect(argv) {
274
420
 
275
421
  if (isMain(import.meta.url)) {
276
422
  try {
277
- console.log(JSON.stringify(collect(process.argv.slice(2)), null, 2));
423
+ const argv = process.argv.slice(2);
424
+ const result = argv[0] === 'embed' ? embed(argv.slice(1)) : collect(argv);
425
+ console.log(JSON.stringify(result, null, 2));
278
426
  } catch (error) {
279
427
  console.error(
280
428
  `play-by-play: ${String(error.stderr || error.message).trim()}`,
@@ -0,0 +1,177 @@
1
+ // Reads a bout's preparation from its records (the interview ledger, decisions.md, findings.json,
2
+ // db-diagram.html) and from git (the size of the deliverable), and hands it to the pure rules of
3
+ // preparation.mjs. Read-only and zero-dependency. What goes inside the page (the diagram, the
4
+ // endpoints, the scripts) is page-embed.mjs.
5
+ import { execFileSync } from 'node:child_process';
6
+ import { existsSync, readFileSync, statSync } from 'node:fs';
7
+ import { join } from 'node:path';
8
+
9
+ import {
10
+ collectContractSources,
11
+ contractSourcesHash,
12
+ } from './contract-endpoints.mjs';
13
+ import { readLedger } from './findings.mjs';
14
+ import { collectFrontRoutes, routesSourcesHash } from './front-routes.mjs';
15
+ import {
16
+ contractFindings,
17
+ INTERVIEW,
18
+ interviewEvents,
19
+ interviewOf,
20
+ parseInterview,
21
+ parseShortstat,
22
+ preparationEnd,
23
+ scaleOf,
24
+ } from './preparation.mjs';
25
+ import {
26
+ madeWeight,
27
+ parseVerdicts,
28
+ VERDICTS,
29
+ verdictSummary,
30
+ } from './verdicts.mjs';
31
+
32
+ export const DIAGRAM = 'db-diagram.html';
33
+
34
+ const git = (dir, args) =>
35
+ execFileSync('git', ['-C', dir, ...args], {
36
+ encoding: 'utf8',
37
+ stdio: ['ignore', 'pipe', 'pipe'],
38
+ maxBuffer: 64 * 1024 * 1024,
39
+ }).trim();
40
+
41
+ /** `origin/<base>` when the remote has it, else the local branch, else null. */
42
+ function baseRef(worktree, base) {
43
+ for (const ref of [`origin/${base}`, base]) {
44
+ try {
45
+ git(worktree, ['rev-parse', '--verify', '--quiet', `${ref}^{commit}`]);
46
+ return ref;
47
+ } catch {
48
+ // try the next one
49
+ }
50
+ }
51
+ return null;
52
+ }
53
+
54
+ /**
55
+ * The deliverable's size against the stack base: as it was weighed (the tip when the bout opened)
56
+ * and now. Null when the worktree is gone or the base cannot be found.
57
+ */
58
+ function sizeOf(state, opened, notes) {
59
+ if (!state.worktree || !existsSync(state.worktree)) return null;
60
+ const base = baseRef(state.worktree, state.base);
61
+ if (!base) {
62
+ notes.push(`pesaje: no base ${state.base} in the worktree; no size`);
63
+ return null;
64
+ }
65
+ const stat = (tip) =>
66
+ parseShortstat(
67
+ git(state.worktree, ['diff', '--shortstat', `${base}...${tip}`]),
68
+ );
69
+ const openTip = opened
70
+ ? git(state.worktree, ['rev-list', '-1', `--before=${opened}`, 'HEAD'])
71
+ : '';
72
+ return {
73
+ base,
74
+ atOpen: openTip ? { sha: openTip.slice(0, 9), ...stat(openTip) } : null,
75
+ now: {
76
+ sha: git(state.worktree, ['rev-parse', '--short=9', 'HEAD']),
77
+ ...stat('HEAD'),
78
+ },
79
+ };
80
+ }
81
+
82
+ /** The wrestler's first commit after the bout opened, or null. */
83
+ function firstCommitAt(state, opened) {
84
+ if (!opened || !state.worktree || !existsSync(state.worktree)) return null;
85
+ const times = git(state.worktree, [
86
+ 'log',
87
+ '--no-merges',
88
+ `--since=${opened}`,
89
+ '--format=%cI',
90
+ 'HEAD',
91
+ ])
92
+ .split('\n')
93
+ .filter(Boolean);
94
+ return times.at(-1) ?? null;
95
+ }
96
+
97
+ /**
98
+ * Whether the page has a board for the judge: changed endpoints or changed routes. When they
99
+ * cannot be read (the worktree is gone) it says so in `notes` and answers true, so the scale
100
+ * stays red instead of greening without the verdicts.
101
+ */
102
+ function hasBoards(dir, notes) {
103
+ try {
104
+ return (
105
+ collectContractSources(dir).roots.length > 0 ||
106
+ collectFrontRoutes(dir).routes.length > 0
107
+ );
108
+ } catch (error) {
109
+ notes.push(
110
+ `pesaje: could not read the contract or routes boards (${error.message}); the scale waits for the verdicts`,
111
+ );
112
+ return true;
113
+ }
114
+ }
115
+
116
+ /**
117
+ * The bout's preparation: `weighIn` (promotion, size, diagram, contract findings, scale),
118
+ * `interview`, and the interview's timed events. `notes` collects what could not be read.
119
+ */
120
+ export function collectPreparation({
121
+ dir,
122
+ state,
123
+ opened,
124
+ decisionsText,
125
+ notes,
126
+ }) {
127
+ const read = (name) =>
128
+ existsSync(join(dir, name)) ? readFileSync(join(dir, name), 'utf8') : '';
129
+ const entries = parseInterview(read(INTERVIEW));
130
+ const scale = scaleOf(decisionsText);
131
+ const until = preparationEnd(scale, firstCommitAt(state, opened));
132
+ const verdictEntries = parseVerdicts(read(VERDICTS));
133
+ const verdicts = verdictEntries.length
134
+ ? verdictSummary(verdictEntries, {
135
+ api: contractSourcesHash(dir),
136
+ routes: routesSourcesHash(dir),
137
+ })
138
+ : null;
139
+ const diagramPath = join(dir, DIAGRAM);
140
+ const diagram = existsSync(diagramPath)
141
+ ? {
142
+ path: diagramPath,
143
+ drawnAt: statSync(diagramPath).mtime.toISOString(),
144
+ placeholder: `<!-- ring:db-diagram #${state.pr} -->`,
145
+ }
146
+ : null;
147
+ if (!entries.length)
148
+ notes.push(
149
+ `entrevista: no ${INTERVIEW}; escalations and answers are logged with \`judges-card.mjs log <pr> escalation|answer\``,
150
+ );
151
+ return {
152
+ weighIn: {
153
+ promotion: {
154
+ pr: state.pr,
155
+ branch: state.branch,
156
+ base: state.base,
157
+ worktree: state.worktree,
158
+ },
159
+ size: sizeOf(state, opened, notes),
160
+ diagram,
161
+ contractFindings: contractFindings(readLedger(dir), until),
162
+ verdicts,
163
+ scale: {
164
+ ...scale,
165
+ approvedByJudge: scale.madeWeight,
166
+ madeWeight: madeWeight(
167
+ scale,
168
+ verdicts,
169
+ scale.madeWeight ? hasBoards(dir, notes) : Boolean(verdicts),
170
+ ),
171
+ },
172
+ },
173
+ interview: interviewOf(entries, decisionsText, until),
174
+ events: interviewEvents(entries, until),
175
+ scale,
176
+ };
177
+ }