@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
@@ -0,0 +1,547 @@
1
+ // The preparation of a bout, told before its first round: «el pesaje del contrato» (what was
2
+ // promoted, the size of the deliverable, the database diagram the judge signs, the cornerman's
3
+ // findings on the contract by severity and the scale: does the contract make weight?) and «la
4
+ // entrevista» (each question the referee escalated to the judge, with its options and the referee's
5
+ // recommendation, the judge's answer with its time, and apart from them the decisions the referee
6
+ // took itself). Not to be confused with `/ring:weigh-in`, which configures the host's gates.
7
+ // PURE and zero-dependency: play-by-play.mjs reads the files and hands them here.
8
+ //
9
+ // The interview's ledger is `<bout dir>/interview.jsonl`, written by `judges-card.mjs log`:
10
+ // { "kind": "escalation", "id": "E1", "at": <ISO>, "body": <the escalation as sent> }
11
+ // { "kind": "answer", "id": "A1", "to": "E1", "at": <ISO>, "judge": <name>, "said": <words> }
12
+ import { isTime, parseDecisionLine } from './chronicle.mjs';
13
+
14
+ export const INTERVIEW = 'interview.jsonl';
15
+
16
+ /** Severities a cornerman writes as the first word of a finding, most severe first. */
17
+ export const SEVERITIES = ['BLOCKER', 'FIX', 'NIT'];
18
+
19
+ const OPTION =
20
+ /\(([a-z])\)\s*([^]*?)(?=\s*(?:·\s*)?\([a-z]\)\s|\.\s*(?:I recommend|[Tt]e recomiendo|[Rr]ecomiendo)\b|$)/g;
21
+ const RECOMMEND =
22
+ /(?:I recommend|[Tt]e recomiendo|[Rr]ecomiendo)\s*\**\s*\(([a-z])\)\s*\**\s*(?:[:,]|because|porque)?\s*([^]*)$/;
23
+
24
+ const plain = (text) =>
25
+ String(text ?? '')
26
+ .replace(/\*\*/g, '')
27
+ .replace(/\s+/g, ' ')
28
+ .trim();
29
+
30
+ /**
31
+ * The questions of an escalation as sent to the judge (`templates/escalation.md`): every numbered
32
+ * item that offers options `(a) … · (b) …`, with the option the referee recommends and why. Items
33
+ * without options (the numbered lists of the report) are not questions. A question that runs over
34
+ * several lines keeps them. PURE.
35
+ */
36
+ export function parseEscalation(text) {
37
+ const items = [];
38
+ for (const line of String(text ?? '').split('\n')) {
39
+ const start = /^\s*(\d+)\.\s+(.*)$/.exec(line);
40
+ if (start) {
41
+ items.push({ n: Number(start[1]), text: start[2] });
42
+ continue;
43
+ }
44
+ const last = items.at(-1);
45
+ if (last && line.trim() && /^\s{2,}\S/.test(line))
46
+ last.text += ` ${line.trim()}`;
47
+ }
48
+ return items
49
+ .map(({ n, text: raw }) => {
50
+ const body = plain(raw);
51
+ const first = body.search(/\([a-z]\)\s/);
52
+ if (first === -1) return null;
53
+ const recommend = RECOMMEND.exec(body);
54
+ const offered = recommend ? body.slice(0, recommend.index) : body;
55
+ const options = [...offered.slice(first).matchAll(OPTION)].map(
56
+ ([, key, option]) => ({
57
+ key,
58
+ text: option.replace(/[\s·.]+$/, '').trim(),
59
+ }),
60
+ );
61
+ return {
62
+ n,
63
+ question: body.slice(0, first).replace(/[\s·—–-]+$/, ''),
64
+ options,
65
+ recommended: recommend?.[1] ?? null,
66
+ why: recommend
67
+ ? recommend[2].replace(/^[\s.:,;]+/, '').trim() || null
68
+ : null,
69
+ };
70
+ })
71
+ .filter(Boolean);
72
+ }
73
+
74
+ /**
75
+ * The options the judge picked, from their own words: «1. a, 2. b», «1a 2b», «3) c». Each pick
76
+ * keeps the question number; anything else the judge said stays in `said`. PURE.
77
+ */
78
+ export function parseAnswer(said) {
79
+ const picks = [];
80
+ for (const [, n, option] of String(said ?? '').matchAll(
81
+ /(?:^|[\s,;])(\d+)\s*[.):-]?\s*\(?([a-z])\)?(?=$|[\s,;.])/g,
82
+ ))
83
+ picks.push({ n: Number(n), option });
84
+ return picks;
85
+ }
86
+
87
+ /** The interview ledger's entries, skipping lines that are not JSON or have no time. PURE. */
88
+ export function parseInterview(text) {
89
+ return String(text ?? '')
90
+ .split('\n')
91
+ .filter((line) => line.trim())
92
+ .map((line) => {
93
+ try {
94
+ return JSON.parse(line);
95
+ } catch {
96
+ return null;
97
+ }
98
+ })
99
+ .filter((entry) => entry && isTime(entry.at))
100
+ .filter((entry) =>
101
+ ['escalation', 'answer', 'context'].includes(entry.kind),
102
+ );
103
+ }
104
+
105
+ /** The next id of a kind in the ledger: E1, E2… for escalations, A1… for answers. PURE. */
106
+ export function nextInterviewId(entries, kind) {
107
+ const prefix = kind === 'escalation' ? 'E' : 'A';
108
+ return `${prefix}${entries.filter((entry) => entry.kind === kind).length + 1}`;
109
+ }
110
+
111
+ /**
112
+ * The escalation an answer goes to: the one named, else the last one asked. Throws when there is
113
+ * none, or the one named does not exist. PURE.
114
+ */
115
+ export function answerTarget(entries, named) {
116
+ const asked = entries.filter((entry) => entry.kind === 'escalation');
117
+ if (!asked.length)
118
+ throw new Error(
119
+ 'no escalation logged for this bout: log it first (`log <pr> escalation <file>`)',
120
+ );
121
+ if (!named) return asked.at(-1).id;
122
+ if (!asked.some((entry) => entry.id === named))
123
+ throw new Error(
124
+ `no escalation ${named} (${asked.map((e) => e.id).join(', ')})`,
125
+ );
126
+ return named;
127
+ }
128
+
129
+ /** The first word of a finding when it is a severity, else "OTHER". PURE. */
130
+ export function severityOf(text) {
131
+ const word = /^\s*([A-Z]+)\b/.exec(String(text ?? ''))?.[1];
132
+ return SEVERITIES.includes(word) ? word : 'OTHER';
133
+ }
134
+
135
+ /**
136
+ * The judge's contract approval, from decisions.md (`approve-contract` writes it): the scale reads
137
+ * green from the first one on, red before. PURE.
138
+ */
139
+ export function scaleOf(decisionsText) {
140
+ const approvals = String(decisionsText ?? '')
141
+ .split('\n')
142
+ .map(parseDecisionLine)
143
+ .filter(Boolean)
144
+ .filter(
145
+ (entry) =>
146
+ entry.at &&
147
+ ['judge', 'juez'].includes(entry.who) &&
148
+ /^contract approved\b/.test(entry.text),
149
+ );
150
+ const first = approvals[0];
151
+ if (!first) return { madeWeight: false, at: null, approvals: [] };
152
+ return {
153
+ madeWeight: true,
154
+ at: first.at,
155
+ approvals: approvals.map((entry) => ({
156
+ at: entry.at,
157
+ id: entry.id,
158
+ files: entry.text.split(' · ')[1] ?? '',
159
+ said: /said: «([^]*)»\s*$/.exec(entry.text)?.[1] ?? null,
160
+ })),
161
+ };
162
+ }
163
+
164
+ /**
165
+ * When the preparation ends: the judge's contract approval, else the wrestler's first commit, else
166
+ * it is still going on (null). PURE.
167
+ */
168
+ export function preparationEnd(scale, firstCommitAt) {
169
+ return scale.at ?? firstCommitAt ?? null;
170
+ }
171
+
172
+ const before = (at, until) =>
173
+ until === null || Date.parse(at) <= Date.parse(until);
174
+
175
+ /**
176
+ * The cornerman's findings on the contract: those reviewed outside a commit range (batch or
177
+ * concept, as §2b reviews a contract) and registered before the preparation ended, counted by
178
+ * severity and by state. PURE.
179
+ */
180
+ export function contractFindings(ledger, until) {
181
+ const findings = (ledger?.findings ?? []).filter(
182
+ (finding) => !finding.range && before(finding.at, until),
183
+ );
184
+ const tally = (key) =>
185
+ findings.reduce(
186
+ (acc, finding) => ({
187
+ ...acc,
188
+ [key(finding)]: (acc[key(finding)] ?? 0) + 1,
189
+ }),
190
+ {},
191
+ );
192
+ return {
193
+ total: findings.length,
194
+ bySeverity: tally((finding) => severityOf(finding.text)),
195
+ byStatus: tally((finding) => finding.status),
196
+ };
197
+ }
198
+
199
+ /**
200
+ * The interview: each escalation asked before the preparation ended, its questions and, under each
201
+ * question, the judge's answers with their time (an answer can come in parts); then the decisions
202
+ * the referee took itself in the same window, marked «del réferi». Escalations asked later are
203
+ * listed in `later`: the rounds tell them. PURE.
204
+ */
205
+ export function interviewOf(entries, decisionsText, until) {
206
+ const answers = entries.filter((entry) => entry.kind === 'answer');
207
+ const contextOf = (escalation) => {
208
+ const added = entries.filter(
209
+ (entry) => entry.kind === 'context' && entry.to === escalation.id,
210
+ );
211
+ return parseContext(added.at(-1)?.body ?? escalation.context ?? '');
212
+ };
213
+ const told = (escalation) => {
214
+ const context = contextOf(escalation);
215
+ const replies = answers.filter((answer) => answer.to === escalation.id);
216
+ const questions = parseEscalation(escalation.body).map((question) => ({
217
+ ...question,
218
+ context: context[question.n] ?? null,
219
+ answers: replies.flatMap((reply) =>
220
+ (reply.picks ?? parseAnswer(reply.said))
221
+ .filter((pick) => pick.n === question.n)
222
+ .map((pick) => ({
223
+ at: reply.at,
224
+ judge: reply.judge ?? null,
225
+ option: pick.option,
226
+ text: pick.text ?? null,
227
+ note: pick.note ?? null,
228
+ attachments: reply.attachments ?? [],
229
+ id: reply.id,
230
+ })),
231
+ ),
232
+ }));
233
+ return {
234
+ id: escalation.id,
235
+ at: escalation.at,
236
+ questions,
237
+ replies: replies.map(({ id, at, judge, said, attachments }) => ({
238
+ id,
239
+ at,
240
+ judge: judge ?? null,
241
+ said,
242
+ attachments: attachments ?? [],
243
+ })),
244
+ unanswered: questions
245
+ .filter((question) => !question.answers.length)
246
+ .map((question) => question.n),
247
+ };
248
+ };
249
+ const escalations = entries
250
+ .filter((entry) => entry.kind === 'escalation')
251
+ .map(told);
252
+ const referee = String(decisionsText ?? '')
253
+ .split('\n')
254
+ .map(parseDecisionLine)
255
+ .filter(Boolean)
256
+ .filter((entry) => entry.at && entry.who === 'referee')
257
+ .filter((entry) => before(entry.at, until))
258
+ .map(({ at, id, text }) => ({ at, id, text, by: 'del réferi' }));
259
+ return {
260
+ until,
261
+ escalations: escalations.filter((e) => before(e.at, until)),
262
+ later: escalations.filter((e) => !before(e.at, until)).map((e) => e.id),
263
+ referee,
264
+ };
265
+ }
266
+
267
+ /**
268
+ * The interview as timed events of the chronicle, with the eight types: an escalation is the
269
+ * referee's (arbitro), an answer is the judge's (juez). Those inside the preparation carry
270
+ * `stage: "entrevista"`, so the rounds do not tell them twice. PURE.
271
+ */
272
+ export function interviewEvents(entries, until) {
273
+ // interviewOf tells an escalation of the window with all its answers, even a late one.
274
+ const told = new Set(
275
+ entries
276
+ .filter((entry) => entry.kind === 'escalation')
277
+ .filter((entry) => before(entry.at, until))
278
+ .map((entry) => entry.id),
279
+ );
280
+ return entries
281
+ .filter((entry) => ['escalation', 'answer'].includes(entry.kind))
282
+ .map((entry) => ({
283
+ at: entry.at,
284
+ type: entry.kind === 'escalation' ? 'arbitro' : 'juez',
285
+ title:
286
+ entry.kind === 'escalation'
287
+ ? `${entry.id} · escalated to the judge`
288
+ : `${entry.id} · the judge answered ${entry.to}`,
289
+ detail:
290
+ entry.kind === 'escalation'
291
+ ? `${parseEscalation(entry.body).length} question(s)`
292
+ : entry.said,
293
+ ref: entry.id,
294
+ source: `file:${INTERVIEW}`,
295
+ ...((
296
+ entry.kind === 'escalation' ? told.has(entry.id) : told.has(entry.to)
297
+ )
298
+ ? { stage: 'entrevista' }
299
+ : {}),
300
+ }));
301
+ }
302
+
303
+ /**
304
+ * Marks the events the preparation tells (the referee's decisions of the interview window and the
305
+ * judge's contract approval) with their stage, so the rounds do not tell them twice. PURE.
306
+ */
307
+ export function withStages(events, interview, scale) {
308
+ const referee = new Set(interview.referee.map((decision) => decision.id));
309
+ const approvals = new Set(scale.approvals.map((approval) => approval.id));
310
+ return events.map((event) => {
311
+ if (event.source !== 'file:decisions.md') return event;
312
+ if (approvals.has(event.ref)) return { ...event, stage: 'pesaje' };
313
+ if (referee.has(event.ref) && event.type === 'arbitro')
314
+ return { ...event, stage: 'entrevista' };
315
+ return event;
316
+ });
317
+ }
318
+
319
+ /** `git diff --shortstat` → { files, insertions, deletions }. PURE. */
320
+ export function parseShortstat(text) {
321
+ const number = (pattern) =>
322
+ Number(pattern.exec(String(text ?? ''))?.[1] ?? 0);
323
+ return {
324
+ files: number(/(\d+) files? changed/),
325
+ insertions: number(/(\d+) insertions?\(\+\)/),
326
+ deletions: number(/(\d+) deletions?\(-\)/),
327
+ };
328
+ }
329
+
330
+ /**
331
+ * The sparring's block once a bout opened from it: folded, with its one-line summary (the call, its
332
+ * time, the evidence links). Before any bout, the sparring is the whole chronicle and nothing
333
+ * folds. PURE.
334
+ */
335
+ export function sparringFold(state, boutCount) {
336
+ const verdict = state.verdict ?? null;
337
+ const note = String(verdict?.note ?? '');
338
+ const links = [
339
+ ...(state.repos ?? []).map((entry) => entry.url).filter(Boolean),
340
+ ...(note.match(/https?:\/\/[^\s)»,;]+/g) ?? []),
341
+ ];
342
+ const withoutLinks = note.replace(/https?:\/\/\S+/g, '');
343
+ return {
344
+ folded: boutCount > 0,
345
+ call: verdict?.call ?? null,
346
+ at: verdict?.at ?? null,
347
+ note: verdict?.note ?? null,
348
+ evidence: [...new Set(links)],
349
+ media: [
350
+ ...new Set(
351
+ withoutLinks.match(/[\w.-]+\.(?:mp4|mov|webm|m4a|mp3|pdf|png)\b/gi) ??
352
+ [],
353
+ ),
354
+ ],
355
+ };
356
+ }
357
+
358
+ /**
359
+ * The preparation with `local` (HH:MM in the human's zone) next to every time it holds, since every
360
+ * time on the page comes from a `local` field. `toLocal` maps an ISO time. PURE.
361
+ */
362
+ export function withLocalTimes({ weighIn, interview }, toLocal) {
363
+ const stamp = (item) => ({ ...item, local: toLocal(item.at) });
364
+ return {
365
+ weighIn: {
366
+ ...weighIn,
367
+ diagram: weighIn.diagram
368
+ ? { ...weighIn.diagram, local: toLocal(weighIn.diagram.drawnAt) }
369
+ : null,
370
+ scale: {
371
+ ...weighIn.scale,
372
+ local: weighIn.scale.at ? toLocal(weighIn.scale.at) : null,
373
+ approvals: weighIn.scale.approvals.map(stamp),
374
+ },
375
+ },
376
+ interview: {
377
+ ...interview,
378
+ escalations: interview.escalations.map((escalation) => ({
379
+ ...stamp(escalation),
380
+ replies: escalation.replies.map(stamp),
381
+ questions: escalation.questions.map((question) => ({
382
+ ...question,
383
+ answers: question.answers.map(stamp),
384
+ })),
385
+ })),
386
+ referee: interview.referee.map(stamp),
387
+ },
388
+ };
389
+ }
390
+
391
+ /**
392
+ * A warning for `summary` when the judges card lists questions for the judge (its hard-list section
393
+ * has a numbered item that is not the template's placeholder) while interview.jsonl has no
394
+ * escalation: the chronicle's interview would come out as a gap. Null otherwise. PURE.
395
+ */
396
+ export function unloggedEscalationNotice(card, entries) {
397
+ if (entries.some((entry) => entry.kind === 'escalation')) return null;
398
+ const section =
399
+ /## Decisions that are yours[^\n]*\n([^]*?)(?=\n## |$)/.exec(
400
+ String(card ?? ''),
401
+ )?.[1] ?? '';
402
+ const asked = section
403
+ .split('\n')
404
+ .some((line) => /^\d+\.\s+(?!<topic>)\S/.test(line));
405
+ if (!asked) return null;
406
+ return 'the judges card has questions for the judge but interview.jsonl has no escalation: log it as sent (`judges-card.mjs log <pr> escalation <file>`) so the chronicle can tell the interview';
407
+ }
408
+
409
+ /**
410
+ * The answers the judge saved from the chronicle page (`respuestas` in the page's db, read with
411
+ * ArtifactData): every object that looks like one, wherever the listing nests it (`data`, `doc`,
412
+ * `fields`…). PURE.
413
+ */
414
+ export function pageAnswerDocs(listing) {
415
+ const found = [];
416
+ const walk = (node) => {
417
+ if (!node || typeof node !== 'object') return;
418
+ if (Array.isArray(node)) return node.forEach(walk);
419
+ if (
420
+ typeof node.escalation === 'string' &&
421
+ node.question != null &&
422
+ isTime(node.answeredAt)
423
+ )
424
+ return void found.push(node);
425
+ Object.values(node).forEach(walk);
426
+ };
427
+ walk(listing);
428
+ return found;
429
+ }
430
+
431
+ /**
432
+ * The interview entries to log for the page's answers of bout `pr`: one per saved answer not
433
+ * logged yet (the same doc saved again is a new answer), at the time the judge saved it, with the
434
+ * option, the text as the judge left it, the note and the attachments. PURE.
435
+ */
436
+ export function pageAnswerEntries(entries, docs, { pr, judge }) {
437
+ const logged = new Set(
438
+ entries.filter((entry) => entry.source).map((entry) => entry.source),
439
+ );
440
+ const asked = new Set(
441
+ entries.filter((e) => e.kind === 'escalation').map((e) => e.id),
442
+ );
443
+ const fresh = [];
444
+ docs
445
+ .map((doc) => ({
446
+ doc,
447
+ match: /^pr(\d+)-(E\d+)$/.exec(doc.escalation),
448
+ source: `page:${doc.escalation}-q${doc.question}@${doc.answeredAt}`,
449
+ }))
450
+ .filter(({ match }) => match && Number(match[1]) === Number(pr))
451
+ .filter(({ match }) => asked.has(match[2]))
452
+ .filter(({ source }) => !logged.has(source))
453
+ .sort((a, b) => Date.parse(a.doc.answeredAt) - Date.parse(b.doc.answeredAt))
454
+ .forEach(({ doc, match, source }) => {
455
+ const n = Number(doc.question);
456
+ const option = String(doc.choice);
457
+ const text = String(doc.text ?? '').trim();
458
+ const note = String(doc.note ?? '').trim();
459
+ fresh.push({
460
+ kind: 'answer',
461
+ id: nextInterviewId([...entries, ...fresh], 'answer'),
462
+ to: match[2],
463
+ at: doc.answeredAt,
464
+ judge,
465
+ said: `${n}. ${option === 'otro' ? 'otro' : option}${text ? ` — ${text}` : ''}${note ? ` · ${note}` : ''}`,
466
+ picks: [{ n, option, text: text || null, note: note || null }],
467
+ attachments: Array.isArray(doc.attachments) ? doc.attachments : [],
468
+ source,
469
+ });
470
+ });
471
+ return fresh;
472
+ }
473
+
474
+ /**
475
+ * The context of each question of an escalation, from its Markdown (`## Contexto <n> · <question>`
476
+ * sections, one per question): `{ <n>: <the section's Markdown> }`. What the judge reads in «Ver más
477
+ * información» before answering. PURE.
478
+ */
479
+ export function parseContext(markdown) {
480
+ const out = {};
481
+ let current = null;
482
+ for (const line of String(markdown ?? '').split('\n')) {
483
+ const heading = /^##\s+(?:Contexto|Context)\s+(\d+)\b/i.exec(line);
484
+ if (heading) {
485
+ current = Number(heading[1]);
486
+ out[current] = [];
487
+ continue;
488
+ }
489
+ if (current !== null) out[current].push(line);
490
+ }
491
+ return Object.fromEntries(
492
+ Object.entries(out).map(([n, lines]) => [n, lines.join('\n').trim()]),
493
+ );
494
+ }
495
+
496
+ /**
497
+ * Each escalation's question contexts for the page, keyed by the page's escalation id
498
+ * (`pr<n>-E<k>`): `{ "pr7-E1": { "1": "<Markdown>" } }`. Escalations without context are left out.
499
+ * PURE.
500
+ */
501
+ export function pageContexts(pr, entries) {
502
+ const interview = interviewOf(entries, '', null);
503
+ return Object.fromEntries(
504
+ interview.escalations
505
+ .map((escalation) => [
506
+ `pr${pr}-${escalation.id}`,
507
+ Object.fromEntries(
508
+ escalation.questions
509
+ .filter((question) => question.context)
510
+ .map((question) => [question.n, question.context]),
511
+ ),
512
+ ])
513
+ .filter(([, contexts]) => Object.keys(contexts).length),
514
+ );
515
+ }
516
+
517
+ /**
518
+ * The escalations whose latest context has no «## Contexto <n>» section (so no question shows «Ver
519
+ * más información»), as `#<pr> E<k>`. Only the latest counts: a context logged again with its
520
+ * sections replaces an old one without them. PURE.
521
+ */
522
+ /** The numbers of the escalation's questions the context has no section for. PURE. */
523
+ export function missingContext(escalationBody, contextBody) {
524
+ const sections = parseContext(contextBody);
525
+ return parseEscalation(escalationBody)
526
+ .map((question) => question.n)
527
+ .filter((n) => !(n in sections));
528
+ }
529
+
530
+ export function contextWarnings(pr, entries) {
531
+ const latest = {};
532
+ const bodies = Object.fromEntries(
533
+ entries
534
+ .filter((entry) => entry.kind === 'escalation')
535
+ .map((entry) => [entry.id, entry.body]),
536
+ );
537
+ entries
538
+ .filter((entry) => entry.kind === 'escalation' && entry.context)
539
+ .forEach((entry) => (latest[entry.id] = entry.context));
540
+ entries
541
+ .filter((entry) => entry.kind === 'context')
542
+ .forEach((entry) => (latest[entry.to] = entry.body));
543
+ return Object.entries(latest)
544
+ .map(([id, body]) => [id, missingContext(bodies[id], body)])
545
+ .filter(([, missing]) => missing.length)
546
+ .map(([id, missing]) => `#${pr} ${id} (questions ${missing.join(', ')})`);
547
+ }
@@ -2,7 +2,8 @@
2
2
  // Round-bells: small temporary draft PRs so the human reviews each concept early. Zero-dependency.
3
3
  //
4
4
  // node round-bell.mjs open <pr> <concept> <sha> [--from <sha>] [--verified <text>]
5
- // [--pending <text>] [--read-first <text>] [--dry-run]
5
+ // [--pending <text>] [--read-first <text>] [--flow-change <text>]
6
+ // [--dry-run]
6
7
  // node round-bell.mjs list <pr> open round-bells, with the commands for the HUMAN to close them
7
8
  //
8
9
  // Round N is pushed as ring/<pr>/round-<N> and opened as a draft stacked on round N-1 (round 1 on
@@ -15,6 +16,7 @@
15
16
  // closes, merges or readies anything. It refuses while a cornerman finding from a range inside the
16
17
  // round is still open, and its body lists every finding the referee discarded or ticketed that the
17
18
  // human has not seen yet, which then count as shown (findings.mjs).
19
+ import { boutFormatGate } from './format-gate.mjs';
18
20
  import { execFileSync } from 'node:child_process';
19
21
  import { readFileSync, writeFileSync } from 'node:fs';
20
22
  import { join } from 'node:path';
@@ -201,7 +203,7 @@ function open(argv) {
201
203
  const { flags, positional } = options(argv);
202
204
  const [pr, concept, ref, extra] = positional;
203
205
  const usage =
204
- 'usage: open <pr> "<concept>" <sha> [--from <sha>] [--verified <text>] [--pending <text>] [--read-first <text>] [--dry-run]; <sha> is the tip of the round, a single commit (quote a concept with spaces)';
206
+ 'usage: open <pr> "<concept>" <sha> [--from <sha>] [--verified <text>] [--pending <text>] [--read-first <text>] [--flow-change <text>] [--dry-run]; <sha> is the tip of the round, a single commit (quote a concept with spaces)';
205
207
  if (extra !== undefined)
206
208
  throw new Error(`unexpected argument "${extra}". ${usage}`);
207
209
  if (!pr || !concept || !ref) throw new Error(usage);
@@ -216,6 +218,8 @@ function open(argv) {
216
218
  gateLimits(),
217
219
  );
218
220
  if (blocked) throw new Error(`round-bell waits: ${blocked}`);
221
+ const format = boutFormatGate(dir, state, sha);
222
+ if (format.refusal) throw new Error(`round-bell waits: ${format.refusal}`);
219
223
 
220
224
  const remote = read('git', [
221
225
  'ls-remote',
@@ -304,6 +308,9 @@ function open(argv) {
304
308
  pending: flags.pending ?? '_Not stated by the referee._',
305
309
  readFirst:
306
310
  flags['read-first'] ?? '_One concept: read the commits in order._',
311
+ flowChange:
312
+ flags['flow-change'] ??
313
+ '_Not applicable: this concept does not change how any flow works today._',
307
314
  findings: unshownSection(ledger),
308
315
  }),
309
316
  );
@@ -0,0 +1,81 @@
1
+ // Reads and writes a bout's technical plan (tech-plan.mjs): `judges-card.mjs plan` changes a
2
+ // point's state or adds one, `page-verdicts` adds each judge's «se mejora», and `play-by-play.mjs
3
+ // embed --plan` puts the plan into the chronicle. Zero-dependency.
4
+ import {
5
+ appendFileSync,
6
+ existsSync,
7
+ readFileSync,
8
+ writeFileSync,
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+
12
+ import { readState } from './config.mjs';
13
+ import { scriptJson } from './contract-endpoints.mjs';
14
+ import {
15
+ parsePlan,
16
+ parsePlanLog,
17
+ PLAN,
18
+ PLAN_LOG,
19
+ planForPage,
20
+ planItem,
21
+ planState,
22
+ withPlanItem,
23
+ withPlanState,
24
+ } from './tech-plan.mjs';
25
+
26
+ const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : null);
27
+
28
+ /** `plan <pr> <n|title> <state> [--ref <sha|round-bell>]`: a new state, stamped in plan.jsonl. */
29
+ export function setPlanState(
30
+ dir,
31
+ target,
32
+ state,
33
+ ref = null,
34
+ at = new Date().toISOString(),
35
+ ) {
36
+ const file = join(dir, PLAN);
37
+ const markdown = read(file);
38
+ if (markdown === null) throw new Error(`no ${PLAN} in ${dir}`);
39
+ const item = planItem(parsePlan(markdown), target);
40
+ const next = planState(state);
41
+ writeFileSync(file, withPlanState(markdown, item, next));
42
+ appendFileSync(
43
+ join(dir, PLAN_LOG),
44
+ `${JSON.stringify({ at, n: item.n, title: item.title, from: item.state, to: next, ref })}\n`,
45
+ );
46
+ return { ...item, from: item.state, to: next };
47
+ }
48
+
49
+ /** Adds a point to the plan (creating the file when it does not exist); returns whether it was new. */
50
+ export function addPlanItem(dir, point, at = new Date().toISOString()) {
51
+ const file = join(dir, PLAN);
52
+ const markdown =
53
+ read(file) ?? `# El plan del técnico · #${readState(dir).pr}\n`;
54
+ const next = withPlanItem(markdown, point);
55
+ if (next === markdown) return false;
56
+ writeFileSync(file, next);
57
+ appendFileSync(
58
+ join(dir, PLAN_LOG),
59
+ `${JSON.stringify({ at, title: point.title, from: null, to: point.state ?? 'pendiente', ref: point.origins?.join(', ') ?? null })}\n`,
60
+ );
61
+ return true;
62
+ }
63
+
64
+ /** The block after `<!-- ring:tech-plan #<pr> -->`: the container and the plan as JSON. */
65
+ export function planBlock(dir) {
66
+ const markdown = read(join(dir, PLAN));
67
+ if (markdown === null) throw new Error(`no ${PLAN} in ${dir}`);
68
+ const pr = readState(dir).pr;
69
+ const plan = {
70
+ pr,
71
+ ...planForPage(markdown, parsePlanLog(read(join(dir, PLAN_LOG)))),
72
+ };
73
+ const total = plan.groups.reduce((sum, group) => sum + group.total, 0);
74
+ return {
75
+ pr,
76
+ html: [
77
+ `<div class="tech-plan" data-ring-plan="${pr}"><p class="endpoints-wait">El plan del técnico (${total} puntos) se dibuja al abrir la página.</p></div>`,
78
+ `<script type="application/json" id="ring-plan-${pr}">${scriptJson(plan)}</script>`,
79
+ ].join('\n'),
80
+ };
81
+ }