@thebattlearena/corner 0.10.0 → 0.10.1-beta.79
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/README.md +102 -26
- package/package.json +2 -2
- package/ring/agents/cornerman.md +21 -5
- package/ring/agents/rudo.md +2 -1
- package/ring/agents/wrestler.md +8 -4
- package/ring/hooks/hooks.json +22 -0
- package/ring/scripts/arch-gate.mjs +407 -0
- package/ring/scripts/brief-build.mjs +130 -0
- package/ring/scripts/brief-guard.mjs +198 -0
- package/ring/scripts/brief.mjs +288 -0
- package/ring/scripts/compact-hook.mjs +110 -0
- package/ring/scripts/config.mjs +44 -0
- package/ring/scripts/engram-memory.mjs +213 -0
- package/ring/scripts/engram.mjs +318 -0
- package/ring/scripts/findings.mjs +116 -6
- package/ring/scripts/judges-card.mjs +144 -3
- package/ring/scripts/mask-guard.mjs +33 -0
- package/ring/scripts/relay.mjs +127 -0
- package/ring/scripts/rule-ids.mjs +124 -0
- package/ring/skills/no-surrender/SKILL.md +5 -0
- package/ring/skills/referee/SKILL.md +62 -18
- package/ring/skills/referee/references/rulebook.md +33 -3
- package/ring/skills/referee/templates/cornerman-batch.md +17 -11
- package/ring/skills/referee/templates/cornerman-concept.md +28 -30
- package/ring/skills/referee/templates/cornerman-range.md +16 -11
- package/ring/skills/referee/templates/wrestler-brief.md +28 -44
- package/ring/skills/referee/templates/wrestler-cutoff.md +4 -0
- package/ring/skills/referee/templates/wrestler-resume.md +4 -0
- package/ring/skills/sparring/SKILL.md +3 -2
- package/ring/skills/title-defense/SKILL.md +2 -2
|
@@ -4,10 +4,14 @@
|
|
|
4
4
|
// node findings.mjs add <pr> --source range|concept|batch|final|copilot|human|contract [--range <from>..<to>]
|
|
5
5
|
// (contract: the judge's «se mejora» on an endpoint, from judges-card page-verdicts)
|
|
6
6
|
// (<text> | --file <report>) one finding, or one per line of a report
|
|
7
|
+
// [--rule <id>] the referee's own rule for the lines that cite none (logged as theirs)
|
|
8
|
+
// A reviewer's finding (range, concept, batch, final, human) cites a rule of the venue
|
|
9
|
+
// in its second field (`<file:line> — R11 — …`); X0 when none covers it.
|
|
7
10
|
// node findings.mjs clean <pr> --range <from>..<to> the cornerman found nothing in that range
|
|
8
11
|
// node findings.mjs applied <pr> F<n>[,F<m>…] <sha> the wrestler applied it in that commit
|
|
9
|
-
// node findings.mjs discard <pr> F<n>[,…] --id D<k> --reason "<why>"
|
|
10
|
-
// node findings.mjs ticket <pr> F<n>[,…] --ticket <id|"to file"> --reason "<why>"
|
|
12
|
+
// node findings.mjs discard <pr> F<n>[,…] --id D<k> --reason "<why>" [--judge "<what the human said>"]
|
|
13
|
+
// node findings.mjs ticket <pr> F<n>[,…] --ticket <id|"to file"> --reason "<why>" [--judge "…"]
|
|
14
|
+
// (--judge is required when a finding cites a rule of `findingsNeedJudge`, P14 by default)
|
|
11
15
|
// node findings.mjs escalate <pr> F<n>[,…] sent to the human; waits on their answer
|
|
12
16
|
// node findings.mjs shown <pr> F<n>[,…] --in "<where the human saw it>"
|
|
13
17
|
// node findings.mjs audit <pr> [--full] counts; --full writes findings-audit.md
|
|
@@ -38,9 +42,18 @@ import {
|
|
|
38
42
|
readState,
|
|
39
43
|
repoSlug,
|
|
40
44
|
} from './config.mjs';
|
|
45
|
+
import { syncAfter } from './engram.mjs';
|
|
41
46
|
import { record } from './live.mjs';
|
|
47
|
+
import {
|
|
48
|
+
citedRules,
|
|
49
|
+
loadRules,
|
|
50
|
+
NO_RULE,
|
|
51
|
+
uncitedFindings,
|
|
52
|
+
} from './rule-ids.mjs';
|
|
42
53
|
|
|
43
54
|
export const LEDGER = 'findings.json';
|
|
55
|
+
/** Sources whose findings come from a review against the rulebook, so each one cites a rule. */
|
|
56
|
+
export const CITED_SOURCES = ['range', 'concept', 'batch', 'final', 'human'];
|
|
44
57
|
export const SOURCES = [
|
|
45
58
|
'range',
|
|
46
59
|
'concept',
|
|
@@ -379,19 +392,85 @@ function scorecard(dir, ledger, label) {
|
|
|
379
392
|
});
|
|
380
393
|
}
|
|
381
394
|
|
|
395
|
+
/**
|
|
396
|
+
* The rule each finding line answers to: the one it cites, or the referee's `--rule` for a line
|
|
397
|
+
* that cites none. Throws listing every line without a valid one. PURE.
|
|
398
|
+
*/
|
|
399
|
+
export function ruledLines(texts, ids, rule) {
|
|
400
|
+
if (rule && rule !== NO_RULE && !ids.has(rule))
|
|
401
|
+
throw new Error(`--rule ${rule} is not a rule of this venue`);
|
|
402
|
+
const lines = texts.map((text) => ({
|
|
403
|
+
text,
|
|
404
|
+
by: citedRules(text) || !rule ? null : 'referee',
|
|
405
|
+
}));
|
|
406
|
+
const pending = lines.filter((line) => !line.by).map((line) => line.text);
|
|
407
|
+
const wrong = uncitedFindings(pending, ids);
|
|
408
|
+
if (wrong.length)
|
|
409
|
+
throw new Error(
|
|
410
|
+
`every finding cites a rule of the venue in its second field (\`<file:line> — R11 — …\`), or X0 when none covers it; rebound the report to the cornerman, or take the line as yours with --rule <id>:\n${wrong.map(({ line, reason }) => ` ${line} (${reason})`).join('\n')}`,
|
|
411
|
+
);
|
|
412
|
+
return lines.map(({ text, by }) => (by ? { text, by, rule } : { text }));
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
/** The findings no rule covers, as proposals for the rulebook in the judges card. */
|
|
416
|
+
function proposeRules(dir, findings) {
|
|
417
|
+
const card = join(dir, 'judges-card.md');
|
|
418
|
+
const uncovered = findings.filter(
|
|
419
|
+
(finding) =>
|
|
420
|
+
finding.rule === NO_RULE || citedRules(finding.text)?.includes(NO_RULE),
|
|
421
|
+
);
|
|
422
|
+
if (!uncovered.length || !existsSync(card)) return;
|
|
423
|
+
const text = uncovered.reduce(
|
|
424
|
+
(current, finding) =>
|
|
425
|
+
withSectionLine(
|
|
426
|
+
current,
|
|
427
|
+
'For the rulebook / precedents',
|
|
428
|
+
`- ${finding.id} (X0, no rule covers it): ${finding.text} — which rule is missing?`,
|
|
429
|
+
),
|
|
430
|
+
readFileSync(card, 'utf8'),
|
|
431
|
+
);
|
|
432
|
+
writeFileSync(card, text);
|
|
433
|
+
}
|
|
434
|
+
|
|
382
435
|
function add([pr, ...argv]) {
|
|
383
436
|
const { values, rest } = flags(argv);
|
|
384
437
|
const { dir, state } = bout(pr);
|
|
385
438
|
const texts = values.file
|
|
386
439
|
? reportLines(readFileSync(values.file, 'utf8'))
|
|
387
440
|
: [rest.join(' ').trim()].filter(Boolean);
|
|
441
|
+
const cited = CITED_SOURCES.includes(values.source);
|
|
442
|
+
const ids = cited
|
|
443
|
+
? loadRules(
|
|
444
|
+
loadConfig(projectDir(), {
|
|
445
|
+
contract: false,
|
|
446
|
+
venue: state.venue ?? 'backend',
|
|
447
|
+
}).config,
|
|
448
|
+
).ids
|
|
449
|
+
: null;
|
|
450
|
+
const lines = cited
|
|
451
|
+
? ruledLines(texts, ids, values.rule)
|
|
452
|
+
: texts.map((text) => ({ text }));
|
|
388
453
|
const { ledger, added } = withFindings(readLedger(dir), {
|
|
389
454
|
source: values.source,
|
|
390
455
|
...rangeOf(state, values.range),
|
|
391
|
-
texts,
|
|
456
|
+
texts: lines.map((line) => line.text),
|
|
392
457
|
at: now(),
|
|
393
458
|
});
|
|
394
|
-
|
|
459
|
+
const authored = new Map(added.map((finding, i) => [finding.id, lines[i]]));
|
|
460
|
+
const findings = ledger.findings.map((finding) =>
|
|
461
|
+
authored.get(finding.id)?.by
|
|
462
|
+
? { ...finding, by: 'referee', rule: authored.get(finding.id).rule }
|
|
463
|
+
: finding,
|
|
464
|
+
);
|
|
465
|
+
scorecard(
|
|
466
|
+
dir,
|
|
467
|
+
writeLedger(dir, { ...ledger, findings }),
|
|
468
|
+
'registró hallazgos',
|
|
469
|
+
);
|
|
470
|
+
proposeRules(
|
|
471
|
+
dir,
|
|
472
|
+
findings.filter((finding) => authored.has(finding.id)),
|
|
473
|
+
);
|
|
395
474
|
for (const finding of added) console.log(`${finding.id} · ${finding.text}`);
|
|
396
475
|
}
|
|
397
476
|
|
|
@@ -422,6 +501,21 @@ function applied([pr, ...argv]) {
|
|
|
422
501
|
console.log(`${ids.join(', ')} applied in ${full.slice(0, 9)}`);
|
|
423
502
|
}
|
|
424
503
|
|
|
504
|
+
/**
|
|
505
|
+
* The findings among `ids` that cite a rule the referee may not settle alone (`findingsNeedJudge`):
|
|
506
|
+
* by the rule the referee recorded or by the rule field of the line. PURE.
|
|
507
|
+
*/
|
|
508
|
+
export function needJudge(findings, ids, rules) {
|
|
509
|
+
const guarded = new Set(rules ?? []);
|
|
510
|
+
return findings.filter(
|
|
511
|
+
(finding) =>
|
|
512
|
+
ids.includes(finding.id) &&
|
|
513
|
+
[finding.rule, ...(citedRules(finding.text) ?? [])].some((rule) =>
|
|
514
|
+
guarded.has(rule),
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
425
519
|
/** Settled by the referee alone: the ledger, decisions.md and the judges card all say so. */
|
|
426
520
|
function settleAlone(pr, argv, kind) {
|
|
427
521
|
const { values, rest } = flags(argv);
|
|
@@ -433,7 +527,21 @@ function settleAlone(pr, argv, kind) {
|
|
|
433
527
|
throw new Error('a discard needs the decision that took it: --id D<k>');
|
|
434
528
|
if (kind === 'ticket' && !values.ticket?.trim())
|
|
435
529
|
throw new Error('a ticket needs its id, or --ticket "to file"');
|
|
436
|
-
const { dir } = bout(pr);
|
|
530
|
+
const { dir, state } = bout(pr);
|
|
531
|
+
const judge = values.judge?.trim();
|
|
532
|
+
const { config } = loadConfig(projectDir(), {
|
|
533
|
+
contract: false,
|
|
534
|
+
venue: state.venue ?? 'backend',
|
|
535
|
+
});
|
|
536
|
+
const guarded = needJudge(
|
|
537
|
+
readLedger(dir).findings,
|
|
538
|
+
ids,
|
|
539
|
+
config.findingsNeedJudge,
|
|
540
|
+
);
|
|
541
|
+
if (guarded.length && !judge)
|
|
542
|
+
throw new Error(
|
|
543
|
+
`${guarded.map((f) => f.id).join(', ')} cite${guarded.length === 1 ? 's' : ''} a rule the referee may not settle alone (${(config.findingsNeedJudge ?? []).join(', ')}): fix it, escalate it, or add --judge "<what the human said>"`,
|
|
544
|
+
);
|
|
437
545
|
const at = now();
|
|
438
546
|
const change =
|
|
439
547
|
kind === 'discard'
|
|
@@ -445,7 +553,7 @@ function settleAlone(pr, argv, kind) {
|
|
|
445
553
|
for (const finding of settled)
|
|
446
554
|
appendFileSync(
|
|
447
555
|
join(dir, 'decisions.md'),
|
|
448
|
-
`- ${at} · ${values.id ?? finding.id} · referee · cornerman finding ${finding.id} ${verb}: ${reason} · «${finding.text}»\n`,
|
|
556
|
+
`- ${at} · ${values.id ?? finding.id} · ${judge ? 'judge' : 'referee'} · cornerman finding ${finding.id} ${verb}: ${reason}${judge ? ` (the human: ${judge})` : ''} · «${finding.text}»\n`,
|
|
449
557
|
);
|
|
450
558
|
const cardPath = join(dir, 'judges-card.md');
|
|
451
559
|
if (existsSync(cardPath)) {
|
|
@@ -546,6 +654,8 @@ if (isMain(import.meta.url)) {
|
|
|
546
654
|
}
|
|
547
655
|
try {
|
|
548
656
|
run(rest);
|
|
657
|
+
// An X0 finding and its status go to Engram (quiet without it).
|
|
658
|
+
if (command !== 'audit') await syncAfter(rest[0]);
|
|
549
659
|
} catch (error) {
|
|
550
660
|
console.error(`findings: ${error.message}`);
|
|
551
661
|
process.exit(1);
|
|
@@ -50,6 +50,17 @@
|
|
|
50
50
|
// State lives in ~/.claude/ring/<owner>/<repo>/<pr>/ (RING_HOME overrides the root), never in the
|
|
51
51
|
// session scratchpad: that one is wiped when the session restarts. state.json stays minimal (worktree, branch, base, lastReviewed,
|
|
52
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 { archConfig, archStatus, SETUP_HINT } from './arch-gate.mjs';
|
|
54
|
+
import {
|
|
55
|
+
conceptsSinceRelay,
|
|
56
|
+
liveLines,
|
|
57
|
+
relayDue,
|
|
58
|
+
undecidedProposals,
|
|
59
|
+
withConceptClosed,
|
|
60
|
+
withProposalDecision,
|
|
61
|
+
wrestlerIds,
|
|
62
|
+
} from './relay.mjs';
|
|
63
|
+
import { resumeSection } from './compact-hook.mjs';
|
|
53
64
|
import { boutFormatGate } from './format-gate.mjs';
|
|
54
65
|
import { execFileSync } from 'node:child_process';
|
|
55
66
|
import {
|
|
@@ -103,6 +114,7 @@ import {
|
|
|
103
114
|
unloggedEscalationNotice,
|
|
104
115
|
} from './preparation.mjs';
|
|
105
116
|
import { handOffWarning, withChronicleUrl } from './sparring-chronicle.mjs';
|
|
117
|
+
import { refereeRecall, syncAfter } from './engram.mjs';
|
|
106
118
|
import { parsePlan, PLAN } from './tech-plan.mjs';
|
|
107
119
|
import { addPlanItem, setPlanState } from './tech-plan-log.mjs';
|
|
108
120
|
import { logPageVerdicts } from './verdicts-log.mjs';
|
|
@@ -237,10 +249,24 @@ function open(argv) {
|
|
|
237
249
|
dir,
|
|
238
250
|
}),
|
|
239
251
|
);
|
|
252
|
+
warnWithoutArchGate(worktree, venue);
|
|
240
253
|
console.log(dir);
|
|
241
254
|
}
|
|
242
255
|
|
|
243
|
-
|
|
256
|
+
/** A bout in a repo with no architecture gate reviews layers only by eye: say so when it opens. */
|
|
257
|
+
function warnWithoutArchGate(worktree, venue) {
|
|
258
|
+
try {
|
|
259
|
+
const config = archConfig(worktree, venue);
|
|
260
|
+
if (archStatus(worktree, config).configured) return;
|
|
261
|
+
console.error(
|
|
262
|
+
`judges-card: WARNING: this repo has no architecture gate, so layer and dependency rules are reviewed only by eye; ${SETUP_HINT}.`,
|
|
263
|
+
);
|
|
264
|
+
} catch {
|
|
265
|
+
// The warning is advice: a repo the gate cannot read still gets its bout.
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
async function summary([pr]) {
|
|
244
270
|
const dir = currentDir(pr);
|
|
245
271
|
const state = readState(dir);
|
|
246
272
|
const tip = git(state.worktree, ['rev-parse', 'HEAD']);
|
|
@@ -283,6 +309,22 @@ function summary([pr]) {
|
|
|
283
309
|
read('judges-card.md').trim(),
|
|
284
310
|
].join('\n'),
|
|
285
311
|
);
|
|
312
|
+
const { config } = loadConfig(projectDir(), {
|
|
313
|
+
contract: false,
|
|
314
|
+
venue: state.venue ?? DEFAULT_VENUE,
|
|
315
|
+
});
|
|
316
|
+
const learned = await refereeRecall(config, {
|
|
317
|
+
slug: repoSlug(projectDir()),
|
|
318
|
+
pr,
|
|
319
|
+
});
|
|
320
|
+
if (learned)
|
|
321
|
+
console.log(
|
|
322
|
+
[
|
|
323
|
+
'',
|
|
324
|
+
'## Learned in earlier bouts of this repo (Engram: fouls not to repeat, verdicts, precedents)',
|
|
325
|
+
learned,
|
|
326
|
+
].join('\n'),
|
|
327
|
+
);
|
|
286
328
|
}
|
|
287
329
|
|
|
288
330
|
/**
|
|
@@ -609,6 +651,11 @@ function close([pr]) {
|
|
|
609
651
|
const dir = currentDir(pr);
|
|
610
652
|
const blocked = auditBlocks(readLedger(dir), { closing: true });
|
|
611
653
|
if (blocked) throw new Error(`bout #${pr} does not close yet: ${blocked}`);
|
|
654
|
+
const proposals = undecidedProposals(
|
|
655
|
+
readFileSync(join(dir, 'judges-card.md'), 'utf8'),
|
|
656
|
+
);
|
|
657
|
+
if (proposals)
|
|
658
|
+
throw new Error(`bout #${pr} does not close yet: ${proposals}`);
|
|
612
659
|
const state = readState(dir);
|
|
613
660
|
const tip = state.lastApproved ?? state.lastReviewed;
|
|
614
661
|
if (tip) {
|
|
@@ -859,8 +906,99 @@ function playByPlayUrl([pr, url]) {
|
|
|
859
906
|
);
|
|
860
907
|
}
|
|
861
908
|
|
|
909
|
+
/**
|
|
910
|
+
* A concept closed after its review: recorded, and the wrestlers that carried it retired. The next
|
|
911
|
+
* concept goes to a fresh wrestler; every `relayEvery` concepts the referee relays first.
|
|
912
|
+
*/
|
|
913
|
+
function concept([pr, name, ...argv]) {
|
|
914
|
+
const { range } = options(argv);
|
|
915
|
+
const dir = currentDir(pr);
|
|
916
|
+
const state = readState(dir);
|
|
917
|
+
const next = withConceptClosed(state, {
|
|
918
|
+
name,
|
|
919
|
+
range,
|
|
920
|
+
at: new Date().toISOString(),
|
|
921
|
+
wrestlers: wrestlerIds(liveLines(dir)),
|
|
922
|
+
});
|
|
923
|
+
writeState(dir, next);
|
|
924
|
+
const { config } = loadConfig(projectDir(), {
|
|
925
|
+
contract: false,
|
|
926
|
+
venue: state.venue ?? DEFAULT_VENUE,
|
|
927
|
+
});
|
|
928
|
+
const steps = [
|
|
929
|
+
`concept ${next.concepts.length} «${name}» closed (${range}); ${next.retiredWrestlers.length} wrestler(s) retired: do not send them the next concept.`,
|
|
930
|
+
relayDue(next, config.relayEvery)
|
|
931
|
+
? `Relay first (${conceptsSinceRelay(next)} concepts since the last one): rewrite the card's «How to resume» so a new session or a compaction resumes from it, then judges-card.mjs relay ${pr}. brief.mjs briefs no wrestler until then.`
|
|
932
|
+
: null,
|
|
933
|
+
`Next concept: brief.mjs wrestler ${pr} --mode resume --vars <file with nextConcept>, launched with prompt "ring-brief: <path>".`,
|
|
934
|
+
];
|
|
935
|
+
console.log(steps.filter(Boolean).join('\n'));
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
/** The referee's relay: the card's «How to resume» is current, written after the last concept. */
|
|
939
|
+
function relay([pr]) {
|
|
940
|
+
const dir = currentDir(pr);
|
|
941
|
+
const state = readState(dir);
|
|
942
|
+
const last = (state.concepts ?? []).at(-1);
|
|
943
|
+
const cardPath = join(dir, 'judges-card.md');
|
|
944
|
+
const resume = resumeSection(readFileSync(cardPath, 'utf8'), 20000);
|
|
945
|
+
const names = last
|
|
946
|
+
? [
|
|
947
|
+
last.name,
|
|
948
|
+
last.range,
|
|
949
|
+
...last.range.split('..').filter((sha) => sha.length >= 7),
|
|
950
|
+
]
|
|
951
|
+
: [];
|
|
952
|
+
if (last && !names.some((name) => name && resume.includes(name)))
|
|
953
|
+
throw new Error(
|
|
954
|
+
`the card's «How to resume» does not mention the last concept («${last.name}», ${last.range}): write where the bout is, what is next and what waits on the human, then relay`,
|
|
955
|
+
);
|
|
956
|
+
if (last && statSync(cardPath).mtimeMs < Date.parse(last.at))
|
|
957
|
+
throw new Error(
|
|
958
|
+
`the card was not touched since concept «${last.name}» closed: rewrite its «How to resume» first`,
|
|
959
|
+
);
|
|
960
|
+
const next = {
|
|
961
|
+
...state,
|
|
962
|
+
relays: [
|
|
963
|
+
...(state.relays ?? []),
|
|
964
|
+
{
|
|
965
|
+
at: new Date().toISOString(),
|
|
966
|
+
afterConcepts: (state.concepts ?? []).length,
|
|
967
|
+
},
|
|
968
|
+
],
|
|
969
|
+
};
|
|
970
|
+
writeState(dir, next);
|
|
971
|
+
console.log(
|
|
972
|
+
`relay recorded after ${(state.concepts ?? []).length} concept(s); the next wrestler can be briefed.`,
|
|
973
|
+
);
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
/** A rulebook proposal of the card, accepted or rejected with its reason. */
|
|
977
|
+
function proposal([pr, n, verdict, ...argv]) {
|
|
978
|
+
const { why } = options(argv);
|
|
979
|
+
const dir = currentDir(pr);
|
|
980
|
+
const cardPath = join(dir, 'judges-card.md');
|
|
981
|
+
const decided = { accept: 'accepted', reject: 'rejected' }[verdict];
|
|
982
|
+
if (!decided)
|
|
983
|
+
throw new Error('usage: proposal <pr> <n> accept|reject --why "<why>"');
|
|
984
|
+
writeFileSync(
|
|
985
|
+
cardPath,
|
|
986
|
+
withProposalDecision(
|
|
987
|
+
readFileSync(cardPath, 'utf8'),
|
|
988
|
+
n,
|
|
989
|
+
decided,
|
|
990
|
+
why,
|
|
991
|
+
new Date().toISOString().slice(0, 10),
|
|
992
|
+
),
|
|
993
|
+
);
|
|
994
|
+
console.log(`proposal ${n} ${decided}`);
|
|
995
|
+
}
|
|
996
|
+
|
|
862
997
|
const COMMANDS = {
|
|
863
998
|
open,
|
|
999
|
+
concept,
|
|
1000
|
+
relay,
|
|
1001
|
+
proposal,
|
|
864
1002
|
'play-by-play-url': playByPlayUrl,
|
|
865
1003
|
'page-answers': pageAnswers,
|
|
866
1004
|
'page-verdicts': pageVerdicts,
|
|
@@ -880,12 +1018,15 @@ if (isMain(import.meta.url)) {
|
|
|
880
1018
|
const run = COMMANDS[command];
|
|
881
1019
|
if (!run) {
|
|
882
1020
|
console.error(
|
|
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 …',
|
|
1021
|
+
'Usage: node judges-card.mjs open|summary|log|did|list|path|close|concept|relay|proposal|approve-contract|title-defense|play-by-play-url|page-answers|page-verdicts|plan …',
|
|
884
1022
|
);
|
|
885
1023
|
process.exit(1);
|
|
886
1024
|
}
|
|
887
1025
|
try {
|
|
888
|
-
run(rest);
|
|
1026
|
+
await run(rest);
|
|
1027
|
+
// What the bout learned goes to Engram after each command that may change it (quiet without it).
|
|
1028
|
+
if (['log', 'concept', 'relay', 'proposal', 'close'].includes(command))
|
|
1029
|
+
await syncAfter(rest[0]);
|
|
889
1030
|
} catch (error) {
|
|
890
1031
|
console.error(`judges-card: ${error.message}`);
|
|
891
1032
|
process.exit(1);
|
|
@@ -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 { boutArchGate } from './arch-gate.mjs';
|
|
13
14
|
import { boutFormatGate } from './format-gate.mjs';
|
|
14
15
|
import { execFileSync } from 'node:child_process';
|
|
15
16
|
import { existsSync, readFileSync, realpathSync } from 'node:fs';
|
|
@@ -881,6 +882,18 @@ const gitRules = [
|
|
|
881
882
|
return `The PR branch ${branch} only receives formatted work: ${reason}`;
|
|
882
883
|
},
|
|
883
884
|
},
|
|
885
|
+
{
|
|
886
|
+
// Third line: what goes up to the PR branch adds no import a layer may not make (P14).
|
|
887
|
+
when: (git, ctx) =>
|
|
888
|
+
git.sub === 'push' && ctx.archBeforePush(git, pushRefspecs(git, ctx)),
|
|
889
|
+
reason: (git, ctx) => {
|
|
890
|
+
const { branch, reason } = ctx.archBeforePush(
|
|
891
|
+
git,
|
|
892
|
+
pushRefspecs(git, ctx),
|
|
893
|
+
);
|
|
894
|
+
return `The PR branch ${branch} only receives work that respects the repo's layers: ${reason}`;
|
|
895
|
+
},
|
|
896
|
+
},
|
|
884
897
|
{
|
|
885
898
|
when: (git, ctx) =>
|
|
886
899
|
git.sub === 'push' && ctx.findingsBeforePush(git, pushRefspecs(git, ctx)),
|
|
@@ -1283,6 +1296,8 @@ function judgeSegment(segment, words, cwd, ctx) {
|
|
|
1283
1296
|
ctx.findingsBeforePushAt(context.path(push.dir), refspecs),
|
|
1284
1297
|
formatBeforePush: (push, refspecs) =>
|
|
1285
1298
|
ctx.formatBeforePushAt(context.path(push.dir), refspecs),
|
|
1299
|
+
archBeforePush: (push, refspecs) =>
|
|
1300
|
+
ctx.archBeforePushAt(context.path(push.dir), refspecs),
|
|
1286
1301
|
isBackupBranch: (push, target) =>
|
|
1287
1302
|
ctx
|
|
1288
1303
|
.backupBranchesAt(context.path(push.dir))
|
|
@@ -1574,6 +1589,24 @@ function realContext(cwd) {
|
|
|
1574
1589
|
}
|
|
1575
1590
|
return null;
|
|
1576
1591
|
},
|
|
1592
|
+
// The first push to a bout's PR branch whose range adds an import its layers forbid, or null.
|
|
1593
|
+
// A repo with no gate configured passes (judges-card open warned about it).
|
|
1594
|
+
archBeforePushAt: (dir, refspecs) => {
|
|
1595
|
+
const slug = insideGitRepo(dirOf(dir)) ? slugOf(dirOf(dir)) : null;
|
|
1596
|
+
if (!slug) return null;
|
|
1597
|
+
for (const { source, target } of refspecs) {
|
|
1598
|
+
const bout = boutsOf(slug).find(({ state }) => state.branch === target);
|
|
1599
|
+
if (!bout || source === null) continue;
|
|
1600
|
+
const sha = git(dirOf(dir), [
|
|
1601
|
+
'rev-parse',
|
|
1602
|
+
'--verify',
|
|
1603
|
+
`${source}^{commit}`,
|
|
1604
|
+
]);
|
|
1605
|
+
const { refusal } = boutArchGate(bout.state, sha);
|
|
1606
|
+
if (refusal) return { branch: target, reason: refusal };
|
|
1607
|
+
}
|
|
1608
|
+
return null;
|
|
1609
|
+
},
|
|
1577
1610
|
contractApprovals: (where) => {
|
|
1578
1611
|
const slug = slugWhere(where);
|
|
1579
1612
|
if (!slug) return {};
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// The bout's continuity: one fresh wrestler per concept, the referee's relay every few concepts, and
|
|
2
|
+
// no close while a rulebook proposal waits for its decision. Zero-dependency.
|
|
3
|
+
//
|
|
4
|
+
// A wrestler that carried a concept carries its context too: after `judges-card.mjs concept`, the
|
|
5
|
+
// next concept goes to a new wrestler (`brief.mjs wrestler <pr> --mode resume`), and brief-guard
|
|
6
|
+
// warns (or blocks) a SendMessage to a retired one. Every `relayEvery` concepts the referee rewrites
|
|
7
|
+
// the card's «how to resume» and records it (`judges-card.mjs relay`): until then brief.mjs does not
|
|
8
|
+
// brief the next wrestler, so a compaction (the SessionStart hook puts that section back) or a new
|
|
9
|
+
// session always resumes from a card that is current. `judges-card.mjs close` refuses while a
|
|
10
|
+
// proposal of «For the rulebook / precedents» (an X0 included) has no decision.
|
|
11
|
+
import { readFileSync } from 'node:fs';
|
|
12
|
+
import { join } from 'node:path';
|
|
13
|
+
|
|
14
|
+
import { LIVE_LOG } from './live.mjs';
|
|
15
|
+
|
|
16
|
+
export const PROPOSALS = 'For the rulebook / precedents';
|
|
17
|
+
const DECIDED = /→\s*(accepted|rejected)\b/;
|
|
18
|
+
|
|
19
|
+
const sectionOf = (lines, heading) => {
|
|
20
|
+
const start = lines.findIndex((line) => line.startsWith(`## ${heading}`));
|
|
21
|
+
if (start < 0) return null;
|
|
22
|
+
const rest = lines.findIndex((line, i) => i > start && /^##\s/.test(line));
|
|
23
|
+
return { start, end: rest < 0 ? lines.length : rest };
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* The proposals of the card's «For the rulebook / precedents» section, in order: each item is a
|
|
28
|
+
* `- ` line with its indented continuation, minus the template's `- <…>` placeholder. PURE.
|
|
29
|
+
*/
|
|
30
|
+
export function proposalsOf(card) {
|
|
31
|
+
const lines = String(card ?? '').split('\n');
|
|
32
|
+
const section = sectionOf(lines, PROPOSALS);
|
|
33
|
+
if (!section) return [];
|
|
34
|
+
const items = [];
|
|
35
|
+
let current = null;
|
|
36
|
+
for (let i = section.start + 1; i < section.end; i += 1) {
|
|
37
|
+
const line = lines[i];
|
|
38
|
+
if (line.startsWith('- ')) {
|
|
39
|
+
current = line.startsWith('- <') ? null : { at: i, text: line };
|
|
40
|
+
if (current) items.push(current);
|
|
41
|
+
} else if (current && /^\s+\S/.test(line)) current.text += `\n${line}`;
|
|
42
|
+
else current = null;
|
|
43
|
+
}
|
|
44
|
+
return items.map((item, index) => ({
|
|
45
|
+
n: index + 1,
|
|
46
|
+
line: item.at,
|
|
47
|
+
text: item.text,
|
|
48
|
+
decided: DECIDED.test(item.text),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The card with proposal `n` (1-based) decided: `→ accepted|rejected (<at>): <why>`. PURE. */
|
|
53
|
+
export function withProposalDecision(card, n, verdict, why, at) {
|
|
54
|
+
if (!['accepted', 'rejected'].includes(verdict))
|
|
55
|
+
throw new Error('a proposal is accept or reject');
|
|
56
|
+
if (!why?.trim()) throw new Error('a decision on a proposal needs --why');
|
|
57
|
+
const proposal = proposalsOf(card).find((item) => item.n === Number(n));
|
|
58
|
+
if (!proposal) throw new Error(`there is no proposal ${n} in the card`);
|
|
59
|
+
if (proposal.decided)
|
|
60
|
+
throw new Error(`proposal ${n} is already decided: ${proposal.text}`);
|
|
61
|
+
const lines = String(card).split('\n');
|
|
62
|
+
lines[proposal.line] =
|
|
63
|
+
`${lines[proposal.line]} → ${verdict} (${at}): ${why.trim()}`;
|
|
64
|
+
return lines.join('\n');
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Why the bout cannot close for its proposals, or null. PURE. */
|
|
68
|
+
export function undecidedProposals(card) {
|
|
69
|
+
const open = proposalsOf(card).filter((item) => !item.decided);
|
|
70
|
+
if (!open.length) return null;
|
|
71
|
+
return `${open.length} rulebook proposal(s) without a decision: ${open
|
|
72
|
+
.map((item) => `${item.n}. ${item.text.split('\n')[0].slice(2, 120)}`)
|
|
73
|
+
.join(
|
|
74
|
+
' · ',
|
|
75
|
+
)}. Decide each with judges-card.mjs proposal <pr> <n> accept|reject --why "<why>" (an accepted one goes to the precedents in its own PR)`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Concepts closed since the last relay. PURE. */
|
|
79
|
+
export const conceptsSinceRelay = (state) => {
|
|
80
|
+
const concepts = state.concepts ?? [];
|
|
81
|
+
const last = (state.relays ?? []).at(-1);
|
|
82
|
+
return last ? concepts.length - last.afterConcepts : concepts.length;
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
/** Is a relay due before the next wrestler? `every` null or 0 turns it off. PURE. */
|
|
86
|
+
export const relayDue = (state, every) =>
|
|
87
|
+
Boolean(every) && conceptsSinceRelay(state) >= every;
|
|
88
|
+
|
|
89
|
+
/** The wrestler agent ids a bout's live feed has seen (what a concept close retires). PURE. */
|
|
90
|
+
export function wrestlerIds(lines) {
|
|
91
|
+
const ids = new Set();
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
try {
|
|
94
|
+
const event = JSON.parse(line);
|
|
95
|
+
const id = /^agent (\S+)$/.exec(event.note ?? '')?.[1];
|
|
96
|
+
if (event.actor === 'luchador' && id) ids.add(id);
|
|
97
|
+
} catch {
|
|
98
|
+
// A torn line is skipped.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [...ids];
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** The state after a concept closes: the concept recorded and its wrestlers retired. PURE. */
|
|
105
|
+
export function withConceptClosed(state, { name, range, at, wrestlers }) {
|
|
106
|
+
if (!name?.trim()) throw new Error('a concept needs its name');
|
|
107
|
+
if (!/^\S+\.\.\S+$/.test(range ?? ''))
|
|
108
|
+
throw new Error('a concept needs its reviewed range: --range <from>..<to>');
|
|
109
|
+
return {
|
|
110
|
+
...state,
|
|
111
|
+
concepts: [...(state.concepts ?? []), { name: name.trim(), range, at }],
|
|
112
|
+
retiredWrestlers: [
|
|
113
|
+
...new Set([...(state.retiredWrestlers ?? []), ...wrestlers]),
|
|
114
|
+
],
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The live lines of a bout, or none. */
|
|
119
|
+
export function liveLines(dir) {
|
|
120
|
+
try {
|
|
121
|
+
return readFileSync(join(dir, LIVE_LOG), 'utf8')
|
|
122
|
+
.split('\n')
|
|
123
|
+
.filter(Boolean);
|
|
124
|
+
} catch {
|
|
125
|
+
return [];
|
|
126
|
+
}
|
|
127
|
+
}
|