@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.
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/ring/agents/cornerman.md +20 -3
- package/ring/agents/wrestler.md +11 -0
- package/ring/hooks/hooks.json +20 -0
- package/ring/scripts/bout-stats-render.mjs +366 -0
- package/ring/scripts/bout-stats.mjs +360 -0
- package/ring/scripts/config.mjs +1 -1
- package/ring/scripts/contract-endpoints.mjs +163 -0
- package/ring/scripts/contract-preview.mjs +34 -14
- package/ring/scripts/fight.mjs +27 -0
- package/ring/scripts/findings.mjs +3 -1
- package/ring/scripts/format-gate.mjs +723 -0
- package/ring/scripts/format-worker.mjs +120 -0
- package/ring/scripts/front-routes.mjs +345 -0
- package/ring/scripts/judges-card.mjs +324 -3
- package/ring/scripts/lookout.mjs +22 -4
- package/ring/scripts/mask-guard.mjs +34 -1
- package/ring/scripts/page-embed.mjs +112 -0
- package/ring/scripts/play-by-play.mjs +155 -7
- package/ring/scripts/preparation-collect.mjs +177 -0
- package/ring/scripts/preparation.mjs +547 -0
- package/ring/scripts/round-bell.mjs +9 -2
- package/ring/scripts/tech-plan-log.mjs +81 -0
- package/ring/scripts/tech-plan.mjs +191 -0
- package/ring/scripts/verdicts-log.mjs +75 -0
- package/ring/scripts/verdicts.mjs +205 -0
- package/ring/skills/play-by-play/SKILL.md +235 -33
- package/ring/skills/play-by-play/templates/blocks.css +196 -0
- package/ring/skills/play-by-play/templates/chronicle.html +358 -55
- package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
- package/ring/skills/play-by-play/templates/interview-form.js +516 -0
- package/ring/skills/play-by-play/templates/now-panel.js +719 -0
- package/ring/skills/referee/SKILL.md +136 -42
- package/ring/skills/referee/references/api-diagram.md +56 -0
- package/ring/skills/referee/references/precedents.md +11 -0
- package/ring/skills/referee/references/rulebook.md +65 -4
- package/ring/skills/referee/templates/api-diagram.html +115 -0
- package/ring/skills/referee/templates/escalation.md +7 -1
- package/ring/skills/referee/templates/round-bell-body.md +4 -0
- package/ring/skills/referee/templates/wrestler-brief.md +3 -0
- package/ring/skills/sparring/SKILL.md +10 -2
- package/ring/skills/sparring/templates/contrato.md +29 -0
- package/ring/skills/sparring/templates/rudo-brief.md +7 -0
- 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>] [--
|
|
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
|
+
}
|