@thebattlearena/corner 0.9.1-beta.70 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/package.json +1 -1
  2. package/plugin/.claude-plugin/plugin.json +1 -1
  3. package/ring/agents/cornerman.md +20 -3
  4. package/ring/agents/wrestler.md +11 -0
  5. package/ring/hooks/hooks.json +20 -0
  6. package/ring/scripts/bout-stats-render.mjs +366 -0
  7. package/ring/scripts/bout-stats.mjs +360 -0
  8. package/ring/scripts/config.mjs +1 -1
  9. package/ring/scripts/contract-endpoints.mjs +163 -0
  10. package/ring/scripts/contract-preview.mjs +34 -14
  11. package/ring/scripts/fight.mjs +27 -0
  12. package/ring/scripts/findings.mjs +3 -1
  13. package/ring/scripts/format-gate.mjs +723 -0
  14. package/ring/scripts/format-worker.mjs +120 -0
  15. package/ring/scripts/front-routes.mjs +345 -0
  16. package/ring/scripts/judges-card.mjs +324 -3
  17. package/ring/scripts/lookout.mjs +22 -4
  18. package/ring/scripts/mask-guard.mjs +34 -1
  19. package/ring/scripts/page-embed.mjs +112 -0
  20. package/ring/scripts/play-by-play.mjs +155 -7
  21. package/ring/scripts/preparation-collect.mjs +177 -0
  22. package/ring/scripts/preparation.mjs +547 -0
  23. package/ring/scripts/round-bell.mjs +9 -2
  24. package/ring/scripts/tech-plan-log.mjs +81 -0
  25. package/ring/scripts/tech-plan.mjs +191 -0
  26. package/ring/scripts/verdicts-log.mjs +75 -0
  27. package/ring/scripts/verdicts.mjs +205 -0
  28. package/ring/skills/play-by-play/SKILL.md +235 -33
  29. package/ring/skills/play-by-play/templates/blocks.css +196 -0
  30. package/ring/skills/play-by-play/templates/chronicle.html +358 -55
  31. package/ring/skills/play-by-play/templates/endpoints-view.js +1153 -0
  32. package/ring/skills/play-by-play/templates/interview-form.js +516 -0
  33. package/ring/skills/play-by-play/templates/now-panel.js +719 -0
  34. package/ring/skills/referee/SKILL.md +136 -42
  35. package/ring/skills/referee/references/api-diagram.md +56 -0
  36. package/ring/skills/referee/references/precedents.md +11 -0
  37. package/ring/skills/referee/references/rulebook.md +65 -4
  38. package/ring/skills/referee/templates/api-diagram.html +115 -0
  39. package/ring/skills/referee/templates/escalation.md +7 -1
  40. package/ring/skills/referee/templates/round-bell-body.md +4 -0
  41. package/ring/skills/referee/templates/wrestler-brief.md +3 -0
  42. package/ring/skills/sparring/SKILL.md +10 -2
  43. package/ring/skills/sparring/templates/contrato.md +29 -0
  44. package/ring/skills/sparring/templates/rudo-brief.md +7 -0
  45. package/ring/skills/weigh-in/SKILL.md +6 -0
@@ -8,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('usage: log <pr> decision|rebound <text>');
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
- writeState(dir, { ...readState(dir), active: false });
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
  }
@@ -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
- once[command](dir, rest[0]);
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|approve <boutDir> <sha-or-ref>\n node lookout.mjs backup <boutDir>',
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
- "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
+ '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, '&amp;')
13
+ .replace(/"/g, '&quot;')
14
+ .replace(/</g, '&lt;')
15
+ .replace(/>/g, '&gt;');
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
+ }