@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
|
@@ -8,6 +8,28 @@
|
|
|
8
8
|
// «sin equipo» (no-team) was marked
|
|
9
9
|
// node judges-card.mjs summary <pr> everything a new session needs to resume
|
|
10
10
|
// node judges-card.mjs log <pr> decision|rebound <text>
|
|
11
|
+
// node judges-card.mjs log <pr> escalation <file> [--context <file>]
|
|
12
|
+
// right after sending an escalation to the judge: its
|
|
13
|
+
// text as sent, stamped (interview.jsonl, E<n>), and the
|
|
14
|
+
// context of each question («## Contexto <n> · …»)
|
|
15
|
+
// node judges-card.mjs log <pr> escalation-context E<n> <file>
|
|
16
|
+
// adds (or replaces) the context of a logged escalation
|
|
17
|
+
// node judges-card.mjs log <pr> answer [E<n>] <the judge's words>
|
|
18
|
+
// when the judge answers («1. a, 2. b»), stamped; to the
|
|
19
|
+
// last escalation unless E<n> names one; --attach
|
|
20
|
+
// <asset id,…> cites the judge's images
|
|
21
|
+
// node judges-card.mjs page-answers <pr> <file>
|
|
22
|
+
// the answers the judge saved from the chronicle page
|
|
23
|
+
// (ArtifactData listing of `respuestas`), each at its
|
|
24
|
+
// saved time; the ones already logged are skipped
|
|
25
|
+
// node judges-card.mjs plan <pr> <n|title> <state> [--ref <sha|round-bell>]
|
|
26
|
+
// a point of plan-tecnico.md changes state (pendiente ·
|
|
27
|
+
// espera al juez · en curso · hecho), stamped in plan.jsonl;
|
|
28
|
+
// `plan <pr> add "<group>" "<title>" …` and `plan <pr> list`
|
|
29
|
+
// node judges-card.mjs page-verdicts <pr> <file>
|
|
30
|
+
// the judge's verdicts on the endpoints (listing of
|
|
31
|
+
// `veredictos`): each «se mejora» becomes a contract
|
|
32
|
+
// finding; says when approve-contract can run
|
|
11
33
|
// node judges-card.mjs did <pr> <text> a "What I did" line in the judges card, stamped by the clock
|
|
12
34
|
// node judges-card.mjs list bouts of this repo
|
|
13
35
|
// node judges-card.mjs path <pr>
|
|
@@ -28,10 +50,12 @@
|
|
|
28
50
|
// State lives in ~/.claude/ring/<owner>/<repo>/<pr>/ (RING_HOME overrides the root), never in the
|
|
29
51
|
// session scratchpad: that one is wiped when the session restarts. state.json stays minimal (worktree, branch, base, lastReviewed,
|
|
30
52
|
// active, venue); a state without venue is a backend bout, from before venues existed; round-bells are read from GitHub and the tip from git, so they never go stale here.
|
|
53
|
+
import { boutFormatGate } from './format-gate.mjs';
|
|
31
54
|
import { execFileSync } from 'node:child_process';
|
|
32
55
|
import {
|
|
33
56
|
appendFileSync,
|
|
34
57
|
existsSync,
|
|
58
|
+
readdirSync,
|
|
35
59
|
readFileSync,
|
|
36
60
|
statSync,
|
|
37
61
|
writeFileSync,
|
|
@@ -65,7 +89,23 @@ import {
|
|
|
65
89
|
recordSparringChronicle,
|
|
66
90
|
} from './fight.mjs';
|
|
67
91
|
import { record } from './live.mjs';
|
|
92
|
+
import {
|
|
93
|
+
answerTarget,
|
|
94
|
+
INTERVIEW,
|
|
95
|
+
interviewOf,
|
|
96
|
+
missingContext,
|
|
97
|
+
nextInterviewId,
|
|
98
|
+
parseEscalation,
|
|
99
|
+
pageAnswerDocs,
|
|
100
|
+
pageAnswerEntries,
|
|
101
|
+
parseContext,
|
|
102
|
+
parseInterview,
|
|
103
|
+
unloggedEscalationNotice,
|
|
104
|
+
} from './preparation.mjs';
|
|
68
105
|
import { handOffWarning, withChronicleUrl } from './sparring-chronicle.mjs';
|
|
106
|
+
import { parsePlan, PLAN } from './tech-plan.mjs';
|
|
107
|
+
import { addPlanItem, setPlanState } from './tech-plan-log.mjs';
|
|
108
|
+
import { logPageVerdicts } from './verdicts-log.mjs';
|
|
69
109
|
import { DEFAULT_VENUE } from './venues.mjs';
|
|
70
110
|
|
|
71
111
|
export const TEMPLATES = join(RING_ROOT, 'skills', 'referee', 'templates');
|
|
@@ -234,6 +274,9 @@ function summary([pr]) {
|
|
|
234
274
|
'## Open rebounds',
|
|
235
275
|
openRebounds(read('rebounds.md')).join('\n') || '(none)',
|
|
236
276
|
'',
|
|
277
|
+
'## Questions the judge has not answered (interview.jsonl)',
|
|
278
|
+
unansweredLines(dir).join('\n') || '(none)',
|
|
279
|
+
'',
|
|
237
280
|
'## Decisions',
|
|
238
281
|
read('decisions.md').trim(),
|
|
239
282
|
'',
|
|
@@ -242,10 +285,278 @@ function summary([pr]) {
|
|
|
242
285
|
);
|
|
243
286
|
}
|
|
244
287
|
|
|
288
|
+
/**
|
|
289
|
+
* The interview entry `log <pr> escalation|answer …` appends, stamped by the clock. An escalation
|
|
290
|
+
* is the text as sent to the judge (a file, or the words); an answer is the judge's own words, to
|
|
291
|
+
* the escalation named (`E<n>` first) or the last one. PURE apart from `read`.
|
|
292
|
+
*/
|
|
293
|
+
export function interviewEntry(entries, kind, words, { at, judge, read }) {
|
|
294
|
+
if (kind === 'escalation-context') {
|
|
295
|
+
const [to, path] = words;
|
|
296
|
+
if (!/^E\d+$/.test(to ?? '') || !path)
|
|
297
|
+
throw new Error('usage: log <pr> escalation-context E<n> <file>');
|
|
298
|
+
answerTarget(entries, to);
|
|
299
|
+
const escalation = entries.find(
|
|
300
|
+
(entry) => entry.kind === 'escalation' && entry.id === to,
|
|
301
|
+
);
|
|
302
|
+
const body = read(path);
|
|
303
|
+
const missing = missingContext(escalation.body, body);
|
|
304
|
+
if (missing.length)
|
|
305
|
+
throw new Error(
|
|
306
|
+
`the context has no «## Contexto <n> · <question>» section for question(s) ${missing.join(', ')}: one per question, numbered like the escalation`,
|
|
307
|
+
);
|
|
308
|
+
return { kind: 'context', to, at, body };
|
|
309
|
+
}
|
|
310
|
+
if (kind === 'escalation') {
|
|
311
|
+
const contextAt = words.indexOf('--context');
|
|
312
|
+
const context = contextAt === -1 ? null : read(words[contextAt + 1]);
|
|
313
|
+
const rest =
|
|
314
|
+
contextAt === -1
|
|
315
|
+
? words
|
|
316
|
+
: [...words.slice(0, contextAt), ...words.slice(contextAt + 2)];
|
|
317
|
+
const body = rest.length > 1 ? rest.join(' ') : read(rest[0]);
|
|
318
|
+
if (!parseEscalation(body).length)
|
|
319
|
+
throw new Error(
|
|
320
|
+
'the escalation has no numbered question with options «(a) … · (b) …» (templates/escalation.md)',
|
|
321
|
+
);
|
|
322
|
+
const missing = context === null ? [] : missingContext(body, context);
|
|
323
|
+
if (missing.length)
|
|
324
|
+
throw new Error(
|
|
325
|
+
`the context has no «## Contexto <n> · <question>» section for question(s) ${missing.join(', ')}: one per question, numbered like the escalation`,
|
|
326
|
+
);
|
|
327
|
+
return {
|
|
328
|
+
kind,
|
|
329
|
+
id: nextInterviewId(entries, kind),
|
|
330
|
+
at,
|
|
331
|
+
body,
|
|
332
|
+
...(context ? { context } : {}),
|
|
333
|
+
};
|
|
334
|
+
}
|
|
335
|
+
const attachAt = words.indexOf('--attach');
|
|
336
|
+
const attachments =
|
|
337
|
+
attachAt === -1
|
|
338
|
+
? []
|
|
339
|
+
: String(words[attachAt + 1] ?? '')
|
|
340
|
+
.split(',')
|
|
341
|
+
.map((id) => id.trim())
|
|
342
|
+
.filter(Boolean);
|
|
343
|
+
const plain =
|
|
344
|
+
attachAt === -1
|
|
345
|
+
? words
|
|
346
|
+
: [...words.slice(0, attachAt), ...words.slice(attachAt + 2)];
|
|
347
|
+
const named = /^E\d+$/.test(plain[0] ?? '') ? plain[0] : null;
|
|
348
|
+
const said = (named ? plain.slice(1) : plain).join(' ').trim();
|
|
349
|
+
if (!said)
|
|
350
|
+
throw new Error('usage: log <pr> answer [E<n>] <the judge’s words>');
|
|
351
|
+
return {
|
|
352
|
+
kind,
|
|
353
|
+
id: nextInterviewId(entries, kind),
|
|
354
|
+
to: answerTarget(entries, named),
|
|
355
|
+
at,
|
|
356
|
+
judge,
|
|
357
|
+
said,
|
|
358
|
+
...(attachments.length ? { attachments } : {}),
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/** The judge's name for an answer: RING_JUDGE, else the git user of the host checkout. */
|
|
363
|
+
function judgeName() {
|
|
364
|
+
if (process.env.RING_JUDGE) return process.env.RING_JUDGE;
|
|
365
|
+
try {
|
|
366
|
+
return git(projectDir(), ['config', 'user.name']) || null;
|
|
367
|
+
} catch {
|
|
368
|
+
return null;
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
/** `- E1 (asked <time>): 2, 5` for each escalation with questions still unanswered. */
|
|
373
|
+
function unansweredLines(dir) {
|
|
374
|
+
const file = join(dir, INTERVIEW);
|
|
375
|
+
const entries = parseInterview(
|
|
376
|
+
existsSync(file) ? readFileSync(file, 'utf8') : '',
|
|
377
|
+
);
|
|
378
|
+
const notice = unloggedEscalationNotice(
|
|
379
|
+
readFileSync(join(dir, 'judges-card.md'), 'utf8'),
|
|
380
|
+
entries,
|
|
381
|
+
);
|
|
382
|
+
if (notice) return [`- warning: ${notice}`];
|
|
383
|
+
return interviewOf(entries, '', null)
|
|
384
|
+
.escalations.filter((escalation) => escalation.unanswered.length)
|
|
385
|
+
.map(
|
|
386
|
+
(escalation) =>
|
|
387
|
+
`- ${escalation.id} (asked ${localStamp(new Date(escalation.at))}): ${escalation.unanswered.join(', ')}`,
|
|
388
|
+
);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function logInterview(pr, kind, words) {
|
|
392
|
+
const dir = currentDir(pr);
|
|
393
|
+
const file = join(dir, INTERVIEW);
|
|
394
|
+
const entries = parseInterview(
|
|
395
|
+
existsSync(file) ? readFileSync(file, 'utf8') : '',
|
|
396
|
+
);
|
|
397
|
+
const entry = interviewEntry(entries, kind, words, {
|
|
398
|
+
at: new Date().toISOString(),
|
|
399
|
+
judge: judgeName(),
|
|
400
|
+
read: (path) => {
|
|
401
|
+
if (!existsSync(path))
|
|
402
|
+
throw new Error(
|
|
403
|
+
`usage: log <pr> escalation <file with the escalation as sent> (no file ${path})`,
|
|
404
|
+
);
|
|
405
|
+
return readFileSync(path, 'utf8');
|
|
406
|
+
},
|
|
407
|
+
});
|
|
408
|
+
appendFileSync(file, `${JSON.stringify(entry)}\n`);
|
|
409
|
+
if (entry.kind === 'context')
|
|
410
|
+
return void console.log(
|
|
411
|
+
`context of ${entry.to} logged at ${localStamp(new Date(entry.at))} (questions ${Object.keys(parseContext(entry.body)).join(', ')})`,
|
|
412
|
+
);
|
|
413
|
+
record(dir, {
|
|
414
|
+
actor: kind === 'escalation' ? 'arbitro' : 'juez',
|
|
415
|
+
state: 'done',
|
|
416
|
+
label: kind === 'escalation' ? 'escaló al juez' : 'respondió',
|
|
417
|
+
detail:
|
|
418
|
+
kind === 'escalation'
|
|
419
|
+
? `${entry.id} · ${parseEscalation(entry.body).length} pregunta(s)`
|
|
420
|
+
: `${entry.to} · ${entry.said}`,
|
|
421
|
+
});
|
|
422
|
+
console.log(`${entry.id} logged at ${localStamp(new Date(entry.at))}`);
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
/**
|
|
426
|
+
* An ArtifactData listing as given: a JSON file, or the directory `out_dir` wrote (every JSON file
|
|
427
|
+
* in it, at any depth).
|
|
428
|
+
*/
|
|
429
|
+
function readListing(path) {
|
|
430
|
+
if (!statSync(path).isDirectory())
|
|
431
|
+
return JSON.parse(readFileSync(path, 'utf8'));
|
|
432
|
+
const walk = (dir) =>
|
|
433
|
+
readdirSync(dir, { withFileTypes: true }).flatMap((entry) =>
|
|
434
|
+
entry.isDirectory()
|
|
435
|
+
? walk(join(dir, entry.name))
|
|
436
|
+
: entry.name.endsWith('.json')
|
|
437
|
+
? [JSON.parse(readFileSync(join(dir, entry.name), 'utf8'))]
|
|
438
|
+
: [],
|
|
439
|
+
);
|
|
440
|
+
return walk(path);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* `page-answers <pr> <file>`: logs the answers the judge saved from the chronicle page (the
|
|
445
|
+
* ArtifactData listing of `respuestas`, saved to a file), each at the time the judge saved it; an
|
|
446
|
+
* answer already logged is skipped.
|
|
447
|
+
*/
|
|
448
|
+
function pageAnswers([pr, file]) {
|
|
449
|
+
if (!pr || !file || !existsSync(file))
|
|
450
|
+
throw new Error(
|
|
451
|
+
'usage: page-answers <pr> <file with the ArtifactData listing of respuestas>',
|
|
452
|
+
);
|
|
453
|
+
const dir = currentDir(pr);
|
|
454
|
+
const ledger = join(dir, INTERVIEW);
|
|
455
|
+
const entries = parseInterview(
|
|
456
|
+
existsSync(ledger) ? readFileSync(ledger, 'utf8') : '',
|
|
457
|
+
);
|
|
458
|
+
const docs = pageAnswerDocs(readListing(file));
|
|
459
|
+
const fresh = pageAnswerEntries(entries, docs, { pr, judge: judgeName() });
|
|
460
|
+
for (const entry of fresh) {
|
|
461
|
+
appendFileSync(ledger, `${JSON.stringify(entry)}\n`);
|
|
462
|
+
record(dir, {
|
|
463
|
+
actor: 'juez',
|
|
464
|
+
state: 'done',
|
|
465
|
+
label: 'respondió',
|
|
466
|
+
detail: `${entry.to} · ${entry.said}`,
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
console.log(
|
|
470
|
+
`${fresh.length} answer(s) logged from the page (${docs.length} saved there)${fresh.length ? `: ${fresh.map((entry) => `${entry.id} → ${entry.to}`).join(', ')}` : ''}`,
|
|
471
|
+
);
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* `page-verdicts <pr> <file>`: logs the judge's verdicts on the endpoints, saved from the chronicle
|
|
476
|
+
* page (the ArtifactData listing of `veredictos`, saved to a file), and says whether every
|
|
477
|
+
* operation is approved with its current fingerprint.
|
|
478
|
+
*/
|
|
479
|
+
function pageVerdicts([pr, file]) {
|
|
480
|
+
if (!pr || !file || !existsSync(file))
|
|
481
|
+
throw new Error(
|
|
482
|
+
'usage: page-verdicts <pr> <file with the ArtifactData listing of veredictos>',
|
|
483
|
+
);
|
|
484
|
+
const result = logPageVerdicts(currentDir(pr), pr, readListing(file));
|
|
485
|
+
console.log(
|
|
486
|
+
`${result.logged} verdict(s) logged, ${result.findings} «se mejora» registered as contract findings (${result.planned} new in the technical plan). ${result.report}`,
|
|
487
|
+
);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
/**
|
|
491
|
+
* `plan <pr> <n|title> <state> [--ref <sha|round-bell>]`: a point of the technical plan changes
|
|
492
|
+
* state, stamped; `plan <pr> add "<group>" "<title>" [--detail <t>] [--origin <ids>] [--state <s>]`
|
|
493
|
+
* adds one; `plan <pr> list` prints it.
|
|
494
|
+
*/
|
|
495
|
+
function plan([pr, target, ...rest]) {
|
|
496
|
+
const dir = currentDir(pr);
|
|
497
|
+
const value = (flag) => {
|
|
498
|
+
const at = rest.indexOf(flag);
|
|
499
|
+
return at === -1 ? null : (rest[at + 1] ?? null);
|
|
500
|
+
};
|
|
501
|
+
if (target === 'list') {
|
|
502
|
+
const markdown = existsSync(join(dir, PLAN))
|
|
503
|
+
? readFileSync(join(dir, PLAN), 'utf8')
|
|
504
|
+
: '';
|
|
505
|
+
return void console.log(
|
|
506
|
+
parsePlan(markdown)
|
|
507
|
+
.groups.flatMap((group) => [
|
|
508
|
+
`## ${group.name}`,
|
|
509
|
+
...group.items.map(
|
|
510
|
+
(item) => `${item.n}. [${item.state}] ${item.title}`,
|
|
511
|
+
),
|
|
512
|
+
])
|
|
513
|
+
.join('\n') || `no ${PLAN} in ${dir}`,
|
|
514
|
+
);
|
|
515
|
+
}
|
|
516
|
+
if (target === 'add') {
|
|
517
|
+
const [group, title] = rest;
|
|
518
|
+
if (!group || !title)
|
|
519
|
+
throw new Error(
|
|
520
|
+
'usage: plan <pr> add "<group>" "<title>" [--detail <t>] [--origin <ids>] [--state <s>]',
|
|
521
|
+
);
|
|
522
|
+
const added = addPlanItem(dir, {
|
|
523
|
+
group,
|
|
524
|
+
title,
|
|
525
|
+
detail: value('--detail') ?? '',
|
|
526
|
+
origins: (value('--origin') ?? '')
|
|
527
|
+
.split(',')
|
|
528
|
+
.map((id) => id.trim())
|
|
529
|
+
.filter(Boolean),
|
|
530
|
+
state: value('--state') ?? 'pendiente',
|
|
531
|
+
});
|
|
532
|
+
return void console.log(
|
|
533
|
+
added
|
|
534
|
+
? `added to «${group}»: ${title}`
|
|
535
|
+
: 'a point with that origin is already in the plan',
|
|
536
|
+
);
|
|
537
|
+
}
|
|
538
|
+
const [state] = rest;
|
|
539
|
+
if (!target || !state)
|
|
540
|
+
throw new Error(
|
|
541
|
+
'usage: plan <pr> <n|title> <pendiente|espera al juez|en curso|hecho> [--ref <sha|round-bell>] | add … | list',
|
|
542
|
+
);
|
|
543
|
+
const changed = setPlanState(dir, target, state, value('--ref'));
|
|
544
|
+
console.log(
|
|
545
|
+
`${changed.n}. ${changed.title}: ${changed.from} → ${changed.to} at ${localStamp(new Date())}`,
|
|
546
|
+
);
|
|
547
|
+
}
|
|
548
|
+
|
|
245
549
|
function log([pr, kind, ...words]) {
|
|
550
|
+
if (
|
|
551
|
+
['escalation', 'escalation-context', 'answer'].includes(kind) &&
|
|
552
|
+
words.length
|
|
553
|
+
)
|
|
554
|
+
return logInterview(pr, kind, words);
|
|
246
555
|
const files = { decision: 'decisions.md', rebound: 'rebounds.md' };
|
|
247
556
|
if (!files[kind] || !words.length)
|
|
248
|
-
throw new Error(
|
|
557
|
+
throw new Error(
|
|
558
|
+
'usage: log <pr> decision|rebound <text> | escalation <file> | answer [E<n>] <the judge’s words>',
|
|
559
|
+
);
|
|
249
560
|
const stamp = new Date().toISOString();
|
|
250
561
|
const box = kind === 'rebound' ? '[ ] ' : '';
|
|
251
562
|
const dir = currentDir(pr);
|
|
@@ -298,7 +609,14 @@ function close([pr]) {
|
|
|
298
609
|
const dir = currentDir(pr);
|
|
299
610
|
const blocked = auditBlocks(readLedger(dir), { closing: true });
|
|
300
611
|
if (blocked) throw new Error(`bout #${pr} does not close yet: ${blocked}`);
|
|
301
|
-
|
|
612
|
+
const state = readState(dir);
|
|
613
|
+
const tip = state.lastApproved ?? state.lastReviewed;
|
|
614
|
+
if (tip) {
|
|
615
|
+
const format = boutFormatGate(dir, state, tip);
|
|
616
|
+
if (format.refusal)
|
|
617
|
+
throw new Error(`bout #${pr} does not close yet: ${format.refusal}`);
|
|
618
|
+
}
|
|
619
|
+
writeState(dir, { ...state, active: false });
|
|
302
620
|
console.log(`bout #${pr} closed; its files stay in ${dir} as the record.`);
|
|
303
621
|
}
|
|
304
622
|
|
|
@@ -544,6 +862,9 @@ function playByPlayUrl([pr, url]) {
|
|
|
544
862
|
const COMMANDS = {
|
|
545
863
|
open,
|
|
546
864
|
'play-by-play-url': playByPlayUrl,
|
|
865
|
+
'page-answers': pageAnswers,
|
|
866
|
+
'page-verdicts': pageVerdicts,
|
|
867
|
+
plan,
|
|
547
868
|
'approve-contract': approveContract,
|
|
548
869
|
'title-defense': titleDefense,
|
|
549
870
|
summary,
|
|
@@ -559,7 +880,7 @@ if (isMain(import.meta.url)) {
|
|
|
559
880
|
const run = COMMANDS[command];
|
|
560
881
|
if (!run) {
|
|
561
882
|
console.error(
|
|
562
|
-
'Usage: node judges-card.mjs open|summary|log|did|list|path|close|approve-contract|title-defense|play-by-play-url …',
|
|
883
|
+
'Usage: node judges-card.mjs open|summary|log|did|list|path|close|approve-contract|title-defense|play-by-play-url|page-answers|page-verdicts|plan …',
|
|
563
884
|
);
|
|
564
885
|
process.exit(1);
|
|
565
886
|
}
|
package/ring/scripts/lookout.mjs
CHANGED
|
@@ -5,6 +5,8 @@
|
|
|
5
5
|
// node lookout.mjs mark <boutDir> <sha-or-ref> record what the referee reviewed
|
|
6
6
|
// (its findings registered first)
|
|
7
7
|
// node lookout.mjs approve <boutDir> <sha-or-ref> record what the cornerman approved
|
|
8
|
+
// [--format-unverified "<why>"] (refused while the range is not
|
|
9
|
+
// formatted; see format-gate.mjs)
|
|
8
10
|
// node lookout.mjs backup <boutDir> push the worktree HEAD to the backup branch
|
|
9
11
|
//
|
|
10
12
|
// `watch` polls the wrestler's worktree and EXITS as soon as its HEAD differs from the last
|
|
@@ -25,6 +27,7 @@ import {
|
|
|
25
27
|
writeState,
|
|
26
28
|
} from './config.mjs';
|
|
27
29
|
import { counts, readLedger, reviewDeclared } from './findings.mjs';
|
|
30
|
+
import { acceptUnverified, boutFormatGate } from './format-gate.mjs';
|
|
28
31
|
|
|
29
32
|
const FULL_SHA = /^[0-9a-f]{40}([0-9a-f]{24})?$/;
|
|
30
33
|
|
|
@@ -292,7 +295,8 @@ export function approvalRefusal(state, sha, ancestor) {
|
|
|
292
295
|
* Records what the cornerman approved: the PR branch may receive up to it (mask-guard checks every
|
|
293
296
|
* push to it). An approval is also a review, so lastReviewed moves forward with it.
|
|
294
297
|
*/
|
|
295
|
-
function approve(dir, ref) {
|
|
298
|
+
function approve(dir, ref, { formatUnverified = null } = {}) {
|
|
299
|
+
if (formatUnverified !== null) acceptUnverified(dir, formatUnverified);
|
|
296
300
|
const state = readState(dir);
|
|
297
301
|
const sha = resolveSha(state, ref);
|
|
298
302
|
const refusal = approvalRefusal(state, sha, (a, b) =>
|
|
@@ -308,6 +312,17 @@ function approve(dir, ref) {
|
|
|
308
312
|
isFullSha(state.lastReviewed) &&
|
|
309
313
|
isAncestor(state.worktree, sha, state.lastReviewed);
|
|
310
314
|
if (!covered) requireDeclared(dir, state, sha);
|
|
315
|
+
// Nothing is approved for the PR branch without the repo's format (sort-imports + prettier).
|
|
316
|
+
const format = boutFormatGate(dir, state, sha);
|
|
317
|
+
if (format.refusal)
|
|
318
|
+
throw Object.assign(
|
|
319
|
+
new Error(`${sha.slice(0, 9)} is not approved: ${format.refusal}`),
|
|
320
|
+
// Same codes as format-gate check: 2 when the format could not be verified, 1 otherwise.
|
|
321
|
+
{
|
|
322
|
+
exitCode:
|
|
323
|
+
format.result.unclean.length || format.result.verified ? 1 : 2,
|
|
324
|
+
},
|
|
325
|
+
);
|
|
311
326
|
writeState(dir, {
|
|
312
327
|
...state,
|
|
313
328
|
lastApproved: sha,
|
|
@@ -322,13 +337,16 @@ if (isMain(import.meta.url)) {
|
|
|
322
337
|
const interval = intervalIndex === -1 ? 60 : Number(rest[intervalIndex + 1]);
|
|
323
338
|
const fail = (error) => {
|
|
324
339
|
console.error(`lookout: ${error.message}`);
|
|
325
|
-
process.exit(1);
|
|
340
|
+
process.exit(error.exitCode ?? 1);
|
|
326
341
|
};
|
|
327
342
|
if (command === 'watch' && dir) watch(dir, interval).catch(fail);
|
|
328
343
|
const once = { mark, approve };
|
|
329
344
|
if (once[command] && dir && rest[0]) {
|
|
330
345
|
try {
|
|
331
|
-
|
|
346
|
+
const at = rest.indexOf('--format-unverified');
|
|
347
|
+
once[command](dir, rest[0], {
|
|
348
|
+
formatUnverified: at === -1 ? null : (rest[at + 1] ?? ''),
|
|
349
|
+
});
|
|
332
350
|
} catch (error) {
|
|
333
351
|
fail(error);
|
|
334
352
|
}
|
|
@@ -351,7 +369,7 @@ if (isMain(import.meta.url)) {
|
|
|
351
369
|
(once[command] && dir && rest[0]);
|
|
352
370
|
if (!valid) {
|
|
353
371
|
console.error(
|
|
354
|
-
'Usage: node lookout.mjs watch <boutDir> [--interval <seconds>]\n node lookout.mjs mark
|
|
372
|
+
'Usage: node lookout.mjs watch <boutDir> [--interval <seconds>]\n node lookout.mjs mark <boutDir> <sha-or-ref>\n node lookout.mjs approve <boutDir> <sha-or-ref> [--format-unverified "<why>"]\n node lookout.mjs backup <boutDir>',
|
|
355
373
|
);
|
|
356
374
|
process.exit(1);
|
|
357
375
|
}
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
//
|
|
11
11
|
// Exit 2 with the reason blocks the command. If the guard itself fails while a bout may be active,
|
|
12
12
|
// it blocks too: a guard that cannot evaluate must not let the command through.
|
|
13
|
+
import { boutFormatGate } from './format-gate.mjs';
|
|
13
14
|
import { execFileSync } from 'node:child_process';
|
|
14
15
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
15
16
|
import { homedir } from 'node:os';
|
|
@@ -735,7 +736,7 @@ const sparringGitRules = [
|
|
|
735
736
|
always: true,
|
|
736
737
|
when: (git, ctx) => git.sub === 'push' && pushesSparringOut(git, ctx),
|
|
737
738
|
reason: () =>
|
|
738
|
-
|
|
739
|
+
'A sparring branch is pushed only to `sparring/*`. Its code leaves only through a KO: `node "$RING/scripts/sparring.mjs" promote <slug>`, which refuses unless the verdict is ko.',
|
|
739
740
|
},
|
|
740
741
|
];
|
|
741
742
|
|
|
@@ -868,6 +869,18 @@ const gitRules = [
|
|
|
868
869
|
return `The PR branch ${branch} only receives what the cornerman approved: ${source} is not inside the approved tip ${approved ?? '(none yet)'}. Get the cornerman's verdict on the range, record it with lookout.mjs approve <bout dir> <sha>, then push up to that sha. Unreviewed work goes to the backup branch (lookout.mjs backup).`;
|
|
869
870
|
},
|
|
870
871
|
},
|
|
872
|
+
{
|
|
873
|
+
// Second line behind lookout approve: what goes up to the PR branch has the repo's format.
|
|
874
|
+
when: (git, ctx) =>
|
|
875
|
+
git.sub === 'push' && ctx.formatBeforePush(git, pushRefspecs(git, ctx)),
|
|
876
|
+
reason: (git, ctx) => {
|
|
877
|
+
const { branch, reason } = ctx.formatBeforePush(
|
|
878
|
+
git,
|
|
879
|
+
pushRefspecs(git, ctx),
|
|
880
|
+
);
|
|
881
|
+
return `The PR branch ${branch} only receives formatted work: ${reason}`;
|
|
882
|
+
},
|
|
883
|
+
},
|
|
871
884
|
{
|
|
872
885
|
when: (git, ctx) =>
|
|
873
886
|
git.sub === 'push' && ctx.findingsBeforePush(git, pushRefspecs(git, ctx)),
|
|
@@ -1268,6 +1281,8 @@ function judgeSegment(segment, words, cwd, ctx) {
|
|
|
1268
1281
|
ctx.pushBeyondApprovalAt(context.path(push.dir), refspecs),
|
|
1269
1282
|
findingsBeforePush: (push, refspecs) =>
|
|
1270
1283
|
ctx.findingsBeforePushAt(context.path(push.dir), refspecs),
|
|
1284
|
+
formatBeforePush: (push, refspecs) =>
|
|
1285
|
+
ctx.formatBeforePushAt(context.path(push.dir), refspecs),
|
|
1271
1286
|
isBackupBranch: (push, target) =>
|
|
1272
1287
|
ctx
|
|
1273
1288
|
.backupBranchesAt(context.path(push.dir))
|
|
@@ -1541,6 +1556,24 @@ function realContext(cwd) {
|
|
|
1541
1556
|
}
|
|
1542
1557
|
return null;
|
|
1543
1558
|
},
|
|
1559
|
+
// The first push to a bout's PR branch whose range is not formatted (or could not be
|
|
1560
|
+
// verified without the referee's override), or null. A check that throws blocks (fail closed).
|
|
1561
|
+
formatBeforePushAt: (dir, refspecs) => {
|
|
1562
|
+
const slug = insideGitRepo(dirOf(dir)) ? slugOf(dirOf(dir)) : null;
|
|
1563
|
+
if (!slug) return null;
|
|
1564
|
+
for (const { source, target } of refspecs) {
|
|
1565
|
+
const bout = boutsOf(slug).find(({ state }) => state.branch === target);
|
|
1566
|
+
if (!bout || source === null) continue;
|
|
1567
|
+
const sha = git(dirOf(dir), [
|
|
1568
|
+
'rev-parse',
|
|
1569
|
+
'--verify',
|
|
1570
|
+
`${source}^{commit}`,
|
|
1571
|
+
]);
|
|
1572
|
+
const { refusal } = boutFormatGate(bout.dir, bout.state, sha);
|
|
1573
|
+
if (refusal) return { branch: target, reason: refusal };
|
|
1574
|
+
}
|
|
1575
|
+
return null;
|
|
1576
|
+
},
|
|
1544
1577
|
contractApprovals: (where) => {
|
|
1545
1578
|
const slug = slugWhere(where);
|
|
1546
1579
|
if (!slug) return {};
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// What `play-by-play.mjs embed` puts inside a written chronicle page, so the one Artifact page
|
|
2
|
+
// carries it without a server or a local file: each bout's database diagram (an iframe whose
|
|
3
|
+
// `srcdoc` is the diagram page, escaped whole, so its own `</body>` never shows up in the page),
|
|
4
|
+
// each bout's «Endpoints del contrato» (contract-endpoints.mjs) and the page's scripts (the
|
|
5
|
+
// interview form, the endpoints view and «Qué necesita de ti ahora», from the skill's templates). Every block sits between a
|
|
6
|
+
// begin and an end marker after its placeholder, so running it again replaces, never duplicates.
|
|
7
|
+
// PURE and zero-dependency.
|
|
8
|
+
|
|
9
|
+
/** Escapes text for a double-quoted attribute, `<` and `>` included. PURE. */
|
|
10
|
+
export const escapeAttribute = (text) =>
|
|
11
|
+
String(text)
|
|
12
|
+
.replace(/&/g, '&')
|
|
13
|
+
.replace(/"/g, '"')
|
|
14
|
+
.replace(/</g, '<')
|
|
15
|
+
.replace(/>/g, '>');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The page with each `<!-- ring:<kind> #<pr> -->` placeholder followed by `blocks[pr]`, between
|
|
19
|
+
* `<!-- ring:<kind> begin -->` and `<!-- ring:<kind> end -->`; a placeholder without a block stays
|
|
20
|
+
* as it is. PURE.
|
|
21
|
+
*/
|
|
22
|
+
export function embedBlocks(page, kind, blocks) {
|
|
23
|
+
const pattern = new RegExp(
|
|
24
|
+
`<!-- ring:${kind} #(\\d+) -->(?:\\s*<!-- ring:${kind} begin -->[^]*?<!-- ring:${kind} end -->)?`,
|
|
25
|
+
'g',
|
|
26
|
+
);
|
|
27
|
+
return String(page).replace(pattern, (whole, pr) => {
|
|
28
|
+
const html = blocks[pr];
|
|
29
|
+
if (html === undefined) return whole;
|
|
30
|
+
return [
|
|
31
|
+
`<!-- ring:${kind} #${pr} -->`,
|
|
32
|
+
`<!-- ring:${kind} begin -->`,
|
|
33
|
+
html,
|
|
34
|
+
`<!-- ring:${kind} end -->`,
|
|
35
|
+
].join('\n');
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Each bout's diagram as an iframe after its `ring:db-diagram #<pr>` placeholder. PURE. */
|
|
40
|
+
export function embedDiagrams(page, diagrams) {
|
|
41
|
+
const blocks = Object.fromEntries(
|
|
42
|
+
Object.entries(diagrams).map(([pr, html]) => [
|
|
43
|
+
pr,
|
|
44
|
+
`<iframe class="diagram" sandbox="allow-same-origin" title="Diagrama de la base de datos #${pr}" loading="lazy" srcdoc="${escapeAttribute(html)}"></iframe>`,
|
|
45
|
+
]),
|
|
46
|
+
);
|
|
47
|
+
return embedBlocks(page, 'db-diagram', blocks);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const SCRIPTS =
|
|
51
|
+
/<!-- ring:page-scripts begin -->[^]*?<!-- ring:page-scripts end -->/;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The page with its scripts (and the styles of what embed puts in it, `blocks.css`) at the very
|
|
55
|
+
* end of the document, between the page-scripts markers:
|
|
56
|
+
* after `<!-- ring:page-scripts -->` when the page has it, else appended. Never placed by
|
|
57
|
+
* searching `</body>`: an embedded diagram carries its own. PURE.
|
|
58
|
+
*/
|
|
59
|
+
export function embedScripts(
|
|
60
|
+
page,
|
|
61
|
+
{ form, endpoints, panel = null, css = null },
|
|
62
|
+
) {
|
|
63
|
+
const text = String(page).replace(SCRIPTS, '').replace(/\s+$/, '');
|
|
64
|
+
const wantsEndpoints = /data-ring-(spec|routes|plan)="/.test(text);
|
|
65
|
+
const wantsPanel =
|
|
66
|
+
Boolean(panel) && /\bdata-ring-(now|open-all)\b/.test(text);
|
|
67
|
+
const block = [
|
|
68
|
+
'<!-- ring:page-scripts begin -->',
|
|
69
|
+
css ? `<style id="ring-blocks">\n${css.trim()}\n</style>` : null,
|
|
70
|
+
`<script>\n${form.trim()}\n</script>`,
|
|
71
|
+
wantsEndpoints
|
|
72
|
+
? `<script type="module">\n${endpoints.trim()}\n</script>`
|
|
73
|
+
: null,
|
|
74
|
+
wantsPanel ? `<script>\n${panel.trim()}\n</script>` : null,
|
|
75
|
+
'<!-- ring:page-scripts end -->',
|
|
76
|
+
]
|
|
77
|
+
.filter(Boolean)
|
|
78
|
+
.join('\n');
|
|
79
|
+
const placeholder = '<!-- ring:page-scripts -->';
|
|
80
|
+
if (text.includes(placeholder))
|
|
81
|
+
return text.replace(placeholder, `${placeholder}\n${block}`);
|
|
82
|
+
return `${text}\n${block}\n`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const jsonInScript = (value) =>
|
|
86
|
+
JSON.stringify(value)
|
|
87
|
+
.replace(/</g, '\\u003c')
|
|
88
|
+
.split(String.fromCharCode(0x2028))
|
|
89
|
+
.join('\\u2028')
|
|
90
|
+
.split(String.fromCharCode(0x2029))
|
|
91
|
+
.join('\\u2029');
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Each escalation's question contexts (`{ "pr7-E1": { "1": "<Markdown>" } }`) as JSON right before
|
|
95
|
+
* its `<ol class="qa" data-escalation="pr7-E1">`, where the interview form reads them for «Ver más
|
|
96
|
+
* información». A previous block is replaced, never duplicated. PURE.
|
|
97
|
+
*/
|
|
98
|
+
export function embedContexts(page, contexts) {
|
|
99
|
+
let text = String(page);
|
|
100
|
+
for (const [escalation, byQuestion] of Object.entries(contexts)) {
|
|
101
|
+
const begin = `<!-- ring:context ${escalation} begin -->`;
|
|
102
|
+
const end = `<!-- ring:context ${escalation} end -->`;
|
|
103
|
+
const previous = new RegExp(`${begin}[^]*?${end}\\n?`, 'g');
|
|
104
|
+
text = text.replace(previous, '');
|
|
105
|
+
const list = new RegExp(`<ol\\b[^>]*data-escalation="${escalation}"[^>]*>`);
|
|
106
|
+
const match = list.exec(text);
|
|
107
|
+
if (!match) continue;
|
|
108
|
+
const block = `${begin}\n<script type="application/json" id="ring-context-${escalation}">${jsonInScript(byQuestion)}</script>\n${end}\n`;
|
|
109
|
+
text = text.slice(0, match.index) + block + text.slice(match.index);
|
|
110
|
+
}
|
|
111
|
+
return text;
|
|
112
|
+
}
|