@thebattlearena/corner 0.10.0 → 0.10.1-beta.78

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.
@@ -0,0 +1,272 @@
1
+ #!/usr/bin/env node
2
+ // The briefs of the ring's agents, generated from the templates with the rules embedded in full, so
3
+ // what a wrestler or a cornerman knows does not depend on what the referee still remembers.
4
+ // Zero-dependency; the rules are brief-build.mjs.
5
+ //
6
+ // node brief.mjs wrestler <pr> --mode start|resume|cutoff --vars <file.json>
7
+ // node brief.mjs cornerman <pr> --mode range|concept|batch --vars <file.json>
8
+ // [--range <A>..<B>] [--files <f1,f2,…>]
9
+ // node brief.mjs verify <brief.md> exit 0 while it holds, 1 when it does not
10
+ //
11
+ // The referee writes only its own values (the assignment, the invariants…) to the --vars JSON; the
12
+ // script fills the rest from the bout, writes `<bout dir>/briefs/<kind>-<mode>-<n>.md`, logs it in
13
+ // `briefs/index.jsonl` and prints the path and the Agent call. The referee passes the file as it is.
14
+ import { execFileSync } from 'node:child_process';
15
+ import {
16
+ appendFileSync,
17
+ existsSync,
18
+ mkdirSync,
19
+ readFileSync,
20
+ realpathSync,
21
+ writeFileSync,
22
+ } from 'node:fs';
23
+ import { join } from 'node:path';
24
+
25
+ import {
26
+ briefStatus,
27
+ decisionsExcerpt,
28
+ fill,
29
+ readSeal,
30
+ rulesBlock,
31
+ seal,
32
+ sha256,
33
+ strayPaths,
34
+ } from './brief-build.mjs';
35
+ import {
36
+ boutDir,
37
+ isMain,
38
+ loadConfig,
39
+ projectDir,
40
+ readState,
41
+ repoSlug,
42
+ ringHome,
43
+ RING_ROOT,
44
+ } from './config.mjs';
45
+ import { loadRules } from './rule-ids.mjs';
46
+
47
+ const TEMPLATES = join(RING_ROOT, 'skills', 'referee', 'templates');
48
+
49
+ /** Per kind and mode: the templates in order, and whether the rules go in (a cut-off resumes the same agent). */
50
+ const SHAPES = {
51
+ wrestler: {
52
+ start: { templates: ['wrestler-brief.md'], rules: true },
53
+ resume: {
54
+ templates: ['wrestler-resume.md', 'wrestler-brief.md'],
55
+ rules: true,
56
+ },
57
+ cutoff: { templates: ['wrestler-cutoff.md'], rules: false },
58
+ },
59
+ cornerman: {
60
+ range: { templates: ['cornerman-range.md'], rules: true },
61
+ concept: { templates: ['cornerman-concept.md'], rules: true },
62
+ batch: { templates: ['cornerman-batch.md'], rules: true },
63
+ },
64
+ };
65
+
66
+ /** What the referee may leave out, and what stands in for it. */
67
+ const OPTIONAL = {
68
+ claims: 'none',
69
+ comments: 'none',
70
+ referencePattern: 'none yet: no round-bell approved so far',
71
+ designReference: 'not applicable: this concept draws no screen',
72
+ };
73
+
74
+ const git = (dir, args) =>
75
+ execFileSync('git', ['-C', dir, ...args], {
76
+ encoding: 'utf8',
77
+ stdio: ['ignore', 'pipe', 'pipe'],
78
+ }).trim();
79
+
80
+ function flags(argv) {
81
+ const values = {};
82
+ const rest = [];
83
+ for (let i = 0; i < argv.length; i += 1) {
84
+ if (!argv[i].startsWith('--')) {
85
+ rest.push(argv[i]);
86
+ continue;
87
+ }
88
+ values[argv[i].slice(2)] = argv[i + 1];
89
+ i += 1;
90
+ }
91
+ return { values, rest };
92
+ }
93
+
94
+ /** The bout of `pr` in this repo: its dir, its state and its venue's config. */
95
+ function boutOf(pr) {
96
+ const dir = boutDir(repoSlug(projectDir()), pr);
97
+ if (!existsSync(join(dir, 'state.json')))
98
+ throw new Error(`no bout #${pr} in this repo (${dir})`);
99
+ const state = readState(dir);
100
+ const { config } = loadConfig(projectDir(), {
101
+ contract: false,
102
+ venue: state.venue ?? 'backend',
103
+ });
104
+ return { dir, state, config };
105
+ }
106
+
107
+ /** The rules block of a bout and its hash: what a brief embeds and what a seal is checked against. */
108
+ function rulesOf(config) {
109
+ const block = rulesBlock(loadRules(config));
110
+ return { block, hash: sha256(block) };
111
+ }
112
+
113
+ /** What the script knows about the bout and its worktree. */
114
+ function boutVars({ dir, state, config }, values) {
115
+ const { worktree } = state;
116
+ const head = git(worktree, ['rev-parse', 'HEAD']);
117
+ const range =
118
+ values.range ??
119
+ (state.lastReviewed ? `${state.lastReviewed}..${head}` : null);
120
+ const commits = range
121
+ ? git(worktree, ['log', '--reverse', '--format=- `%h` %s', range]) ||
122
+ '(none)'
123
+ : '(no range)';
124
+ const files = values.files
125
+ ? values.files
126
+ .split(',')
127
+ .map((file) => file.trim())
128
+ .filter(Boolean)
129
+ : range
130
+ ? git(worktree, ['diff', '--name-only', range])
131
+ .split('\n')
132
+ .filter(Boolean)
133
+ : [];
134
+ const gate = (name) => config.gates?.[name] ?? 'none configured';
135
+ return {
136
+ files,
137
+ vars: {
138
+ pr: state.pr,
139
+ branch: state.branch,
140
+ base: `origin/${state.base}`,
141
+ worktree,
142
+ boutDir: dir,
143
+ ring: RING_ROOT,
144
+ venue: config.venue,
145
+ range: range ?? '',
146
+ commits,
147
+ headSha: head.slice(0, 12),
148
+ lastSubject: git(worktree, ['log', '-1', '--format=%s']),
149
+ gateTypecheck: gate('typecheck'),
150
+ gateTest: gate('test'),
151
+ gateLint: gate('lint'),
152
+ forbidAiTrailers: String(config.forbidAiTrailers),
153
+ diskFloor: String(config.diskFloorGb ?? 5),
154
+ backupBranch: String(config.backupBranch ?? 'none').replace(
155
+ '{pr}',
156
+ state.pr,
157
+ ),
158
+ lenses: (config.lenses ?? []).join('; '),
159
+ leftoverPaths: files.join(', ') || 'the files of the range',
160
+ files: files.map((file) => `- ${file}`).join('\n'),
161
+ },
162
+ };
163
+ }
164
+
165
+ const real = (path) => (existsSync(path) ? realpathSync(path) : path);
166
+
167
+ function generate(kind, [pr, ...argv]) {
168
+ const { values } = flags(argv);
169
+ const shape = SHAPES[kind]?.[values.mode];
170
+ if (!pr || !shape)
171
+ throw new Error(
172
+ `usage: ${kind} <pr> --mode ${Object.keys(SHAPES[kind]).join('|')} --vars <file.json>`,
173
+ );
174
+ const bout = boutOf(pr);
175
+ const own = values.vars ? JSON.parse(readFileSync(values.vars, 'utf8')) : {};
176
+ const roots = [
177
+ projectDir(),
178
+ bout.state.worktree,
179
+ bout.dir,
180
+ RING_ROOT,
181
+ ringHome(),
182
+ ].map(real);
183
+ const stray = strayPaths(own, roots);
184
+ if (stray.length)
185
+ throw new Error(
186
+ `paths outside the repo, the worktree, the bout dir and the ring (a session scratchpad is wiped on restart; what the human asked for goes to decisions.md or the technical plan):\n${stray.map(({ name, path }) => ` ${name}: ${path}`).join('\n')}`,
187
+ );
188
+ const { vars, files } = boutVars(bout, values);
189
+ const text = fill(
190
+ shape.templates
191
+ .map((name) => readFileSync(join(TEMPLATES, name), 'utf8'))
192
+ .join('\n'),
193
+ { ...OPTIONAL, ...vars, ...own },
194
+ );
195
+ const decisionsPath = join(bout.dir, 'decisions.md');
196
+ const decisions = decisionsExcerpt(
197
+ existsSync(decisionsPath) ? readFileSync(decisionsPath, 'utf8') : '',
198
+ files,
199
+ );
200
+ const rules = rulesOf(bout.config);
201
+ // The task goes first and the rules last, as the reference: a brief that opens with the rules
202
+ // reads as a checklist, and a 2026-11 lab on #1212 found fewer real problems that way.
203
+ const body = [
204
+ text.trim(),
205
+ '## Decisions already taken (excerpt: every foul, the ones on these files, the last 20)',
206
+ decisions,
207
+ `The whole ledger: \`${decisionsPath}\`.`,
208
+ ...(shape.rules ? ['---', rules.block] : []),
209
+ ].join('\n\n');
210
+ const briefs = join(bout.dir, 'briefs');
211
+ mkdirSync(briefs, { recursive: true });
212
+ const index = join(briefs, 'index.jsonl');
213
+ const n = existsSync(index)
214
+ ? readFileSync(index, 'utf8').split('\n').filter(Boolean).length + 1
215
+ : 1;
216
+ const meta = {
217
+ kind,
218
+ mode: values.mode,
219
+ pr,
220
+ n,
221
+ rules: shape.rules ? rules.hash : 'none',
222
+ };
223
+ const file = join(briefs, `${kind}-${values.mode}-${n}.md`);
224
+ writeFileSync(file, seal(body, meta));
225
+ appendFileSync(
226
+ index,
227
+ `${JSON.stringify({ ...meta, range: vars.range || null, at: new Date().toISOString(), vars: Object.keys(own) })}\n`,
228
+ );
229
+ const launch = bout.config.agents?.[kind] ?? {};
230
+ console.log(file);
231
+ console.log(
232
+ `Agent{subagent_type: "ring:${kind}", model: "${launch.model}", effort: "${launch.effort}", prompt: "ring-brief: ${file}"}`,
233
+ );
234
+ }
235
+
236
+ /**
237
+ * Whether a brief's text still holds: sealed by brief.mjs, unedited, and on the rules in force
238
+ * today. Returns the seal's meta too (kind, mode, pr) so a caller can check it is the right brief.
239
+ */
240
+ export function checkBrief(text) {
241
+ const sealed = readSeal(text);
242
+ const rulesNow = (meta) =>
243
+ meta.rules === 'none' ? 'none' : rulesOf(boutOf(meta.pr).config).hash;
244
+ return {
245
+ ...briefStatus(text, sealed ? rulesNow(sealed.meta) : null),
246
+ meta: sealed?.meta ?? null,
247
+ };
248
+ }
249
+
250
+ function verify([path]) {
251
+ if (!path) throw new Error('usage: verify <brief.md>');
252
+ const status = checkBrief(readFileSync(path, 'utf8'));
253
+ console.log(
254
+ status.ok ? 'brief holds' : `brief does not hold: ${status.reason}`,
255
+ );
256
+ return status.ok ? 0 : 1;
257
+ }
258
+
259
+ if (isMain(import.meta.url)) {
260
+ const [command, ...rest] = process.argv.slice(2);
261
+ try {
262
+ if (command === 'verify') process.exit(verify(rest));
263
+ if (!SHAPES[command])
264
+ throw new Error(
265
+ 'usage: brief.mjs wrestler|cornerman <pr> --mode … | verify <brief.md>',
266
+ );
267
+ generate(command, rest);
268
+ } catch (error) {
269
+ console.error(`brief: ${error.message}`);
270
+ process.exit(1);
271
+ }
272
+ }
@@ -0,0 +1,110 @@
1
+ #!/usr/bin/env node
2
+ // Hook on SessionStart (matcher `compact`): after a compaction the referee's context loses what it
3
+ // knew of the bout. This puts back, in under 10,000 characters (Claude Code's cap for hook output),
4
+ // what must not be lost: which bout it referees and where its card and ledger live, the
5
+ // architecture checklist, the bout's fouls (what must not happen again), the card's latest «how to
6
+ // resume», and the rule that every wrestler and cornerman is launched with a brief from brief.mjs.
7
+ // Zero-dependency. Only a session registered in a bout gets it; any error ends in exit 0, silent.
8
+ import { existsSync, readFileSync } from 'node:fs';
9
+ import { join } from 'node:path';
10
+
11
+ import {
12
+ isMain,
13
+ loadConfig,
14
+ projectDir,
15
+ readState,
16
+ RING_ROOT,
17
+ } from './config.mjs';
18
+ import { findBout, sessionRegistered } from './live.mjs';
19
+ import { checklistLines, loadRules } from './rule-ids.mjs';
20
+
21
+ /** Claude Code's cap for SessionStart output, with room to spare. */
22
+ export const BUDGET = 9500;
23
+ const RESUME = /^##\s+(How to resume|Cómo reanudar)\b/i;
24
+
25
+ /** The card's first «how to resume» section (the newest is on top), cut to `max` characters. PURE. */
26
+ export function resumeSection(card, max = 2500) {
27
+ const lines = String(card ?? '').split('\n');
28
+ const start = lines.findIndex((line) => RESUME.test(line));
29
+ if (start < 0) return '';
30
+ const end = lines.findIndex((line, i) => i > start && /^##\s/.test(line));
31
+ const text = lines
32
+ .slice(start + 1, end < 0 ? undefined : end)
33
+ .join('\n')
34
+ .trim();
35
+ return text.length > max ? `${text.slice(0, max)}…` : text;
36
+ }
37
+
38
+ /** The ledger's fouls, newest last, cut to `max` characters from the oldest. PURE. */
39
+ export function foulLines(decisions, max = 3000) {
40
+ const fouls = String(decisions ?? '')
41
+ .split('\n')
42
+ .filter((line) => /\bFOUL\b/.test(line));
43
+ const kept = [];
44
+ let size = 0;
45
+ for (const line of fouls.reverse()) {
46
+ if (size + line.length + 1 > max) break;
47
+ kept.unshift(line);
48
+ size += line.length + 1;
49
+ }
50
+ const dropped = fouls.length - kept.length;
51
+ return [
52
+ ...(dropped ? [`(${dropped} older fouls in decisions.md)`] : []),
53
+ ...kept,
54
+ ].join('\n');
55
+ }
56
+
57
+ /** The text a compacted referee gets back. PURE. */
58
+ export function compactContext({ pr, dir, checklist, fouls, resume }) {
59
+ const text = [
60
+ `# Ring: you referee bout #${pr} (context restored after a compaction)`,
61
+ '',
62
+ `Reread before acting: the judges card \`${join(dir, 'judges-card.md')}\` and the ledger \`${join(dir, 'decisions.md')}\`. Every wrestler and cornerman is launched with a brief from \`node ${RING_ROOT}/scripts/brief.mjs\` and \`prompt: "ring-brief: <path>"\`; never a brief written by hand. Every finding cites its rule id (or X0).`,
63
+ '',
64
+ '## Architecture checklist (the full rules go in every brief)',
65
+ checklist || '(none for this venue)',
66
+ '',
67
+ '## Fouls of this bout (never again)',
68
+ fouls || '(none)',
69
+ ...(resume ? ['', '## How to resume (from the card)', resume] : []),
70
+ ].join('\n');
71
+ return text.length > BUDGET ? `${text.slice(0, BUDGET)}…` : text;
72
+ }
73
+
74
+ const read = (path) => (existsSync(path) ? readFileSync(path, 'utf8') : '');
75
+
76
+ /** The hook on one input: the text to print, or null. */
77
+ export function restore(input) {
78
+ if (input?.hook_event_name !== 'SessionStart') return null;
79
+ if (input.source && input.source !== 'compact') return null;
80
+ const dir = findBout({ cwd: input.cwd });
81
+ if (!dir || !sessionRegistered(dir, input.session_id)) return null;
82
+ const state = readState(dir);
83
+ const { config } = loadConfig(projectDir(process.env, input.cwd), {
84
+ contract: false,
85
+ venue: state.venue ?? 'backend',
86
+ });
87
+ return compactContext({
88
+ pr: state.pr,
89
+ dir,
90
+ checklist: checklistLines(loadRules(config).entries).join('\n'),
91
+ fouls: foulLines(read(join(dir, 'decisions.md'))),
92
+ resume: resumeSection(read(join(dir, 'judges-card.md'))),
93
+ });
94
+ }
95
+
96
+ if (isMain(import.meta.url)) {
97
+ let raw = '';
98
+ process.stdin.setEncoding('utf8');
99
+ process.stdin.on('data', (chunk) => (raw += chunk));
100
+ process.stdin.on('end', () => {
101
+ let out = null;
102
+ try {
103
+ out = restore(JSON.parse(raw));
104
+ } catch {
105
+ out = null;
106
+ }
107
+ if (out) process.stdout.write(out);
108
+ process.exit(0);
109
+ });
110
+ }
@@ -106,6 +106,9 @@ export const DEFAULT_HEAVY_COMMANDS = [
106
106
  'make test',
107
107
  ];
108
108
 
109
+ /** What brief-guard does with a launch whose brief brief.mjs did not generate. */
110
+ export const BRIEF_GUARD_MODES = ['warn', 'block', 'off'];
111
+
109
112
  export const DEFAULTS = Object.freeze({
110
113
  gates: { typecheck: null, test: null, lint: null },
111
114
  validateCommand: null,
@@ -136,6 +139,17 @@ export const DEFAULTS = Object.freeze({
136
139
  previewTimeoutMin: 20,
137
140
  staleDays: 7,
138
141
  }),
142
+ // brief-guard on every Agent launch of a wrestler or cornerman in a bout: 'warn' lets a brief
143
+ // that brief.mjs did not generate through and says so; 'block' refuses it; 'off' does nothing.
144
+ briefGuard: 'warn',
145
+ // The model and effort of each ring agent, passed on every launch. Who writes runs on the
146
+ // cheaper model at high effort; who reviews keeps the strongest one (what slipped in bout #1223
147
+ // slipped in review). A host overrides a role in `.claude/ring.json` (`agents.wrestler.model`).
148
+ agents: Object.freeze({
149
+ wrestler: Object.freeze({ model: 'sonnet', effort: 'high' }),
150
+ rudo: Object.freeze({ model: 'sonnet', effort: 'high' }),
151
+ cornerman: Object.freeze({ model: 'opus', effort: 'high' }),
152
+ }),
139
153
  });
140
154
 
141
155
  const GATES = ['typecheck', 'test', 'lint'];
@@ -331,6 +345,10 @@ export function mergeConfig(detected, host) {
331
345
  throw new Error(
332
346
  `.claude/ring.json: "delegation" must be one of ${DELEGATION_LEVELS.join(', ')}, not ${JSON.stringify(own.delegation)}`,
333
347
  );
348
+ if ('briefGuard' in own && !BRIEF_GUARD_MODES.includes(own.briefGuard))
349
+ throw new Error(
350
+ `.claude/ring.json: "briefGuard" must be one of ${BRIEF_GUARD_MODES.join(', ')}, not ${JSON.stringify(own.briefGuard)}`,
351
+ );
334
352
  validateVenues(own.venues, VENUE_NAMES);
335
353
  return {
336
354
  ...DEFAULTS,
@@ -349,6 +367,12 @@ export function mergeConfig(detected, host) {
349
367
  ? null
350
368
  : { ...DEFAULTS.findingsGate, ...(own.findingsGate ?? {}) },
351
369
  sparring: { ...DEFAULTS.sparring, ...(own.sparring ?? {}) },
370
+ agents: Object.fromEntries(
371
+ Object.entries(DEFAULTS.agents).map(([role, launch]) => [
372
+ role,
373
+ { ...launch, ...(own.agents?.[role] ?? {}) },
374
+ ]),
375
+ ),
352
376
  };
353
377
  }
354
378
 
@@ -4,6 +4,9 @@
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
12
  // node findings.mjs discard <pr> F<n>[,…] --id D<k> --reason "<why>"
@@ -39,8 +42,16 @@ import {
39
42
  repoSlug,
40
43
  } from './config.mjs';
41
44
  import { record } from './live.mjs';
45
+ import {
46
+ citedRules,
47
+ loadRules,
48
+ NO_RULE,
49
+ uncitedFindings,
50
+ } from './rule-ids.mjs';
42
51
 
43
52
  export const LEDGER = 'findings.json';
53
+ /** Sources whose findings come from a review against the rulebook, so each one cites a rule. */
54
+ export const CITED_SOURCES = ['range', 'concept', 'batch', 'final', 'human'];
44
55
  export const SOURCES = [
45
56
  'range',
46
57
  'concept',
@@ -379,19 +390,85 @@ function scorecard(dir, ledger, label) {
379
390
  });
380
391
  }
381
392
 
393
+ /**
394
+ * The rule each finding line answers to: the one it cites, or the referee's `--rule` for a line
395
+ * that cites none. Throws listing every line without a valid one. PURE.
396
+ */
397
+ export function ruledLines(texts, ids, rule) {
398
+ if (rule && rule !== NO_RULE && !ids.has(rule))
399
+ throw new Error(`--rule ${rule} is not a rule of this venue`);
400
+ const lines = texts.map((text) => ({
401
+ text,
402
+ by: citedRules(text) || !rule ? null : 'referee',
403
+ }));
404
+ const pending = lines.filter((line) => !line.by).map((line) => line.text);
405
+ const wrong = uncitedFindings(pending, ids);
406
+ if (wrong.length)
407
+ throw new Error(
408
+ `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')}`,
409
+ );
410
+ return lines.map(({ text, by }) => (by ? { text, by, rule } : { text }));
411
+ }
412
+
413
+ /** The findings no rule covers, as proposals for the rulebook in the judges card. */
414
+ function proposeRules(dir, findings) {
415
+ const card = join(dir, 'judges-card.md');
416
+ const uncovered = findings.filter(
417
+ (finding) =>
418
+ finding.rule === NO_RULE || citedRules(finding.text)?.includes(NO_RULE),
419
+ );
420
+ if (!uncovered.length || !existsSync(card)) return;
421
+ const text = uncovered.reduce(
422
+ (current, finding) =>
423
+ withSectionLine(
424
+ current,
425
+ 'For the rulebook / precedents',
426
+ `- ${finding.id} (X0, no rule covers it): ${finding.text} — which rule is missing?`,
427
+ ),
428
+ readFileSync(card, 'utf8'),
429
+ );
430
+ writeFileSync(card, text);
431
+ }
432
+
382
433
  function add([pr, ...argv]) {
383
434
  const { values, rest } = flags(argv);
384
435
  const { dir, state } = bout(pr);
385
436
  const texts = values.file
386
437
  ? reportLines(readFileSync(values.file, 'utf8'))
387
438
  : [rest.join(' ').trim()].filter(Boolean);
439
+ const cited = CITED_SOURCES.includes(values.source);
440
+ const ids = cited
441
+ ? loadRules(
442
+ loadConfig(projectDir(), {
443
+ contract: false,
444
+ venue: state.venue ?? 'backend',
445
+ }).config,
446
+ ).ids
447
+ : null;
448
+ const lines = cited
449
+ ? ruledLines(texts, ids, values.rule)
450
+ : texts.map((text) => ({ text }));
388
451
  const { ledger, added } = withFindings(readLedger(dir), {
389
452
  source: values.source,
390
453
  ...rangeOf(state, values.range),
391
- texts,
454
+ texts: lines.map((line) => line.text),
392
455
  at: now(),
393
456
  });
394
- scorecard(dir, writeLedger(dir, ledger), 'registró hallazgos');
457
+ const authored = new Map(added.map((finding, i) => [finding.id, lines[i]]));
458
+ const findings = ledger.findings.map((finding) =>
459
+ authored.get(finding.id)?.by
460
+ ? { ...finding, by: 'referee', rule: authored.get(finding.id).rule }
461
+ : finding,
462
+ );
463
+ scorecard(
464
+ dir,
465
+ writeLedger(dir, { ...ledger, findings }),
466
+ 'registró hallazgos',
467
+ );
468
+ proposeRules(
469
+ dir,
470
+ findings.filter((finding) => authored.has(finding.id)),
471
+ );
395
472
  for (const finding of added) console.log(`${finding.id} · ${finding.text}`);
396
473
  }
397
474
 
@@ -0,0 +1,124 @@
1
+ // The rules a bout runs on, read from its rulebooks and precedent files: the ids that exist (so a
2
+ // finding cites a real one) and the one-line checklist every brief opens with. Zero-dependency.
3
+ import { existsSync, readFileSync } from 'node:fs';
4
+
5
+ /**
6
+ * `**R11. SRP, SOLID…**` → { id: 'R11', title }. The heading of every rule and precedent; a long
7
+ * heading wraps onto the next lines, so the bold runs across them until its closing `**`.
8
+ */
9
+ const ENTRY = /^\*\*([A-Z]{1,2}\d+)\.\s+([\s\S]+?)\*\*/gm;
10
+
11
+ /**
12
+ * What the human's reviews keep finding and line-by-line reviews let through (bout #1223 and the
13
+ * review Tapes of 11 nov 2026: optionals and ternaries dragged downstream, defensive code, code
14
+ * coupled to each channel, repositories off the host's standard, hand-written types, raw provider
15
+ * responses): every brief opens with these in one line each, before the full text. Ids a venue
16
+ * does not define are skipped.
17
+ */
18
+ export const ARCHITECTURE_CHECKLIST = [
19
+ 'R3',
20
+ 'R6',
21
+ 'R8',
22
+ 'R11',
23
+ 'R12',
24
+ 'R15',
25
+ 'R16',
26
+ 'R17',
27
+ 'R18',
28
+ 'R19',
29
+ 'R20',
30
+ 'R21',
31
+ 'R22',
32
+ 'R23',
33
+ 'R24',
34
+ 'R25',
35
+ 'R26',
36
+ 'R27',
37
+ 'U6',
38
+ 'U7',
39
+ 'P14',
40
+ 'P16',
41
+ ];
42
+
43
+ /** The id that marks a finding no rule covers; it becomes a proposal for the rulebook. */
44
+ export const NO_RULE = 'X0';
45
+
46
+ /** Every rule or precedent heading of a Markdown file. PURE. */
47
+ export function ruleEntries(markdown) {
48
+ return [...String(markdown ?? '').matchAll(ENTRY)].map(([, id, title]) => ({
49
+ id,
50
+ title: title.replace(/\s+/g, ' ').replace(/\.$/, ''),
51
+ }));
52
+ }
53
+
54
+ /**
55
+ * The rule files of a venue, in the order a brief embeds them: the rulebook, the venue's
56
+ * rulebooks, the bundled precedents, the venue's precedents and the host's (HPn, which win).
57
+ */
58
+ export function ruleFiles(config) {
59
+ return [
60
+ config.rulebookPath,
61
+ ...(config.venueRulebookPaths ?? []),
62
+ config.bundledPrecedentsPath,
63
+ ...(config.venuePrecedentsPaths ?? []),
64
+ config.precedentsPath,
65
+ ].filter((path) => path && existsSync(path));
66
+ }
67
+
68
+ /** The rule files with their text and the ids they define. */
69
+ export function loadRules(config) {
70
+ const sources = ruleFiles(config).map((path) => ({
71
+ path,
72
+ text: readFileSync(path, 'utf8'),
73
+ }));
74
+ const entries = sources.flatMap((source) => ruleEntries(source.text));
75
+ return { sources, entries, ids: new Set(entries.map((entry) => entry.id)) };
76
+ }
77
+
78
+ /** The checklist as Markdown lines, from whichever of its ids the rules define. PURE. */
79
+ export function checklistLines(entries, ids = ARCHITECTURE_CHECKLIST) {
80
+ const byId = new Map(entries.map((entry) => [entry.id, entry.title]));
81
+ return ids
82
+ .filter((id) => byId.has(id))
83
+ .map((id) => `- **${id}** ${byId.get(id)}`);
84
+ }
85
+
86
+ const ID = /\b[A-Z]{1,2}\d+\b/g;
87
+ const FORMAT_LINE = /(?:^|\s—\s)format\s—\s|NO VERIFICADO/;
88
+
89
+ /**
90
+ * The rule ids a finding line cites in its rule field: the second field of
91
+ * `<file:line> — <rule id> — <what> — <how>`, or of a table row `| file | rule | … |`. The field
92
+ * may carry a severity or a note around the ids (`BLOCKER (P16/PR11)`, `X0 (security)`); only the
93
+ * ids count. Null when the field names none. PURE.
94
+ */
95
+ export function citedRules(line) {
96
+ const text = String(line).trim();
97
+ const fields = text.startsWith('|')
98
+ ? text
99
+ .split('|')
100
+ .map((field) => field.trim())
101
+ .filter(Boolean)
102
+ : text.split(/\s+—\s+/);
103
+ const field = (fields[1] ?? '').replace(/`/g, '').trim();
104
+ const ids = field.match(ID);
105
+ return ids ? [...new Set(ids)] : null;
106
+ }
107
+
108
+ /**
109
+ * The finding lines that do not cite a real rule, with why: no rule field, or ids no loaded rule
110
+ * defines. A format line and `X0` (no rule covers it) are valid. PURE.
111
+ */
112
+ export function uncitedFindings(lines, ids) {
113
+ return lines
114
+ .map((line) => ({ line, cited: citedRules(line) }))
115
+ .filter(({ line }) => !FORMAT_LINE.test(line))
116
+ .map(({ line, cited }) => {
117
+ if (!cited) return { line, reason: 'no rule id in its second field' };
118
+ const unknown = cited.filter((id) => id !== NO_RULE && !ids.has(id));
119
+ return unknown.length
120
+ ? { line, reason: `no rule ${unknown.join(', ')} in this venue` }
121
+ : null;
122
+ })
123
+ .filter(Boolean);
124
+ }